fix
This commit is contained in:
parent
ef76defb28
commit
bfd1ea72a5
|
|
@ -0,0 +1,429 @@
|
||||||
|
# درخواست APIهای داشبورد
|
||||||
|
|
||||||
|
سلام تیم بکاند 👋
|
||||||
|
|
||||||
|
برای تکمیل صفحه داشبورد نیاز به APIهای زیر داریم. لطفاً این APIها را پیادهسازی کنید:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. API آمار کلی داشبورد (Dashboard Overview Stats)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/stats`
|
||||||
|
|
||||||
|
**Description:** دریافت آمار کلی سیستم شامل سفارشات، درآمد، کاربران و محصولات
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orders": {
|
||||||
|
"total": 1250,
|
||||||
|
"pending": 45,
|
||||||
|
"completed": 1100,
|
||||||
|
"cancelled": 105,
|
||||||
|
"today_count": 23,
|
||||||
|
"today_amount": 45000000
|
||||||
|
},
|
||||||
|
"revenue": {
|
||||||
|
"total": 2500000000,
|
||||||
|
"today": 45000000,
|
||||||
|
"this_month": 350000000,
|
||||||
|
"last_month": 320000000,
|
||||||
|
"growth_percentage": 9.4
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"total": 8500,
|
||||||
|
"active": 7200,
|
||||||
|
"new_today": 15,
|
||||||
|
"new_this_month": 450
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"total": 1250,
|
||||||
|
"active": 1100,
|
||||||
|
"low_stock": 25,
|
||||||
|
"out_of_stock": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API فروش ماهانه (Monthly Sales Chart)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/monthly-sales`
|
||||||
|
|
||||||
|
**Description:** دریافت آمار فروش به صورت ماهانه برای نمایش در چارت
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `months` (optional, default: 6) - تعداد ماههای گذشته که باید برگردانده شود
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"month": "فروردین",
|
||||||
|
"month_number": 1,
|
||||||
|
"year": 1403,
|
||||||
|
"total_amount": 350000000,
|
||||||
|
"order_count": 450
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month": "اردیبهشت",
|
||||||
|
"month_number": 2,
|
||||||
|
"year": 1403,
|
||||||
|
"total_amount": 420000000,
|
||||||
|
"order_count": 520
|
||||||
|
}
|
||||||
|
// ... برای تعداد ماههای درخواستی
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** دادهها باید به ترتیب زمانی (از قدیمیترین به جدیدترین) مرتب باشند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API روند رشد (Growth Trend)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/growth-trend`
|
||||||
|
|
||||||
|
**Description:** دریافت روند رشد سیستم در بازههای زمانی مختلف
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `period` (optional, default: "monthly") - نوع بازه زمانی: `"daily"`, `"weekly"`, `"monthly"`
|
||||||
|
- `days` (optional, default: 30) - تعداد روزها برای `period=daily`
|
||||||
|
- `weeks` (optional, default: 12) - تعداد هفتهها برای `period=weekly`
|
||||||
|
- `months` (optional, default: 12) - تعداد ماهها برای `period=monthly`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"period": "monthly",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"date": "1403/01",
|
||||||
|
"revenue": 350000000,
|
||||||
|
"orders": 450,
|
||||||
|
"users": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "1403/02",
|
||||||
|
"revenue": 420000000,
|
||||||
|
"orders": 520,
|
||||||
|
"users": 135
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
"growth_rate": 9.4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API کاربران اخیر (Recent Users)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/recent-users`
|
||||||
|
|
||||||
|
**Description:** دریافت لیست کاربران جدید که اخیراً ثبتنام کردهاند
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `limit` (optional, default: 10) - تعداد کاربران
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"first_name": "علی",
|
||||||
|
"last_name": "احمدی",
|
||||||
|
"username": "ali_ahmadi",
|
||||||
|
"email": "ali@example.com",
|
||||||
|
"phone": "09123456789",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2024-01-15T10:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"first_name": "فاطمه",
|
||||||
|
"last_name": "حسینی",
|
||||||
|
"username": "fateme_hosseini",
|
||||||
|
"email": "fateme@example.com",
|
||||||
|
"phone": "09123456790",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2024-01-14T15:20:00Z"
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
"total": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** کاربران باید به ترتیب تاریخ ثبتنام (جدیدترین اول) مرتب باشند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API توزیع دستگاههای کاربری (Device Distribution)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/device-distribution`
|
||||||
|
|
||||||
|
**Description:** دریافت آمار توزیع دستگاههای کاربری (دسکتاپ، موبایل، تبلت)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"device_type": "desktop",
|
||||||
|
"label": "دسکتاپ",
|
||||||
|
"count": 4500,
|
||||||
|
"percentage": 45.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device_type": "mobile",
|
||||||
|
"label": "موبایل",
|
||||||
|
"count": 3500,
|
||||||
|
"percentage": 35.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device_type": "tablet",
|
||||||
|
"label": "تبلت",
|
||||||
|
"count": 2000,
|
||||||
|
"percentage": 19.3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 10000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** درصدها باید به صورت عدد صحیح (بدون اعشار) برگردانده شوند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API سفارشهای اخیر (Recent Orders)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/recent-orders`
|
||||||
|
|
||||||
|
**Description:** دریافت لیست آخرین سفارشهای ثبت شده
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `limit` (optional, default: 10) - تعداد سفارشها
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"id": 1234,
|
||||||
|
"order_number": "ORD-2024-001234",
|
||||||
|
"customer_name": "علی احمدی",
|
||||||
|
"customer_id": 45,
|
||||||
|
"total_amount": 2500000,
|
||||||
|
"status": "pending",
|
||||||
|
"payment_status": "paid",
|
||||||
|
"created_at": "2024-01-15T14:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1233,
|
||||||
|
"order_number": "ORD-2024-001233",
|
||||||
|
"customer_name": "فاطمه حسینی",
|
||||||
|
"customer_id": 46,
|
||||||
|
"total_amount": 1800000,
|
||||||
|
"status": "completed",
|
||||||
|
"payment_status": "paid",
|
||||||
|
"created_at": "2024-01-15T13:20:00Z"
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
"total": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** سفارشها باید به ترتیب تاریخ ایجاد (جدیدترین اول) مرتب باشند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API محصولات پرفروش (Top Selling Products)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/top-products`
|
||||||
|
|
||||||
|
**Description:** دریافت لیست محصولات پرفروش
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `limit` (optional, default: 5) - تعداد محصولات
|
||||||
|
- `period` (optional, default: "month") - بازه زمانی: `"day"`, `"week"`, `"month"`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"id": 45,
|
||||||
|
"name": "محصول نمونه",
|
||||||
|
"sku": "PRD-001",
|
||||||
|
"sold_count": 250,
|
||||||
|
"revenue": 125000000,
|
||||||
|
"image_url": "https://example.com/images/product-45.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 78,
|
||||||
|
"name": "محصول دیگر",
|
||||||
|
"sku": "PRD-002",
|
||||||
|
"sold_count": 180,
|
||||||
|
"revenue": 90000000,
|
||||||
|
"image_url": "https://example.com/images/product-78.jpg"
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** محصولات باید به ترتیب تعداد فروش (بیشترین اول) مرتب باشند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API آمار مقایسهای (Comparison Stats)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/admin/dashboard/comparison`
|
||||||
|
|
||||||
|
**Description:** دریافت آمار مقایسهای بین دوره فعلی و دوره قبلی
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `compare_with` (optional, default: "last_period") - نوع مقایسه: `"last_period"`, `"last_year"`
|
||||||
|
- `period` (optional, default: "month") - نوع دوره: `"day"`, `"week"`, `"month"`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"revenue": 350000000,
|
||||||
|
"orders": 450,
|
||||||
|
"users": 120
|
||||||
|
},
|
||||||
|
"previous": {
|
||||||
|
"revenue": 320000000,
|
||||||
|
"orders": 410,
|
||||||
|
"users": 105
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"revenue": {
|
||||||
|
"amount": 30000000,
|
||||||
|
"percentage": 9.4,
|
||||||
|
"trend": "up"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"amount": 40,
|
||||||
|
"percentage": 9.8,
|
||||||
|
"trend": "up"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"amount": 15,
|
||||||
|
"percentage": 14.3,
|
||||||
|
"trend": "up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- `trend` میتواند `"up"`, `"down"` یا `"stable"` باشد
|
||||||
|
- درصدها میتوانند اعشار داشته باشند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم و الزامات:
|
||||||
|
|
||||||
|
1. **احراز هویت:** همه APIها باید نیاز به احراز هویت داشته باشند (Bearer Token)
|
||||||
|
|
||||||
|
2. **فیلتر تاریخ:** در صورت امکان، همه APIها باید قابلیت فیلتر بر اساس بازه زمانی را داشته باشند:
|
||||||
|
|
||||||
|
- `from_date` (optional) - تاریخ شروع
|
||||||
|
- `to_date` (optional) - تاریخ پایان
|
||||||
|
|
||||||
|
3. **فرمت تاریخ:**
|
||||||
|
|
||||||
|
- تاریخها در Response باید به فرمت ISO 8601 برگردانده شوند: `"2024-01-15T14:30:00Z"`
|
||||||
|
- تاریخهای ورودی میتوانند به فرمت ISO 8601 یا timestamp باشند
|
||||||
|
|
||||||
|
4. **فرمت مبالغ:**
|
||||||
|
|
||||||
|
- همه مبالغ به تومان هستند
|
||||||
|
- مبالغ باید به صورت عدد (integer) برگردانده شوند
|
||||||
|
|
||||||
|
5. **ترتیب دادهها:**
|
||||||
|
|
||||||
|
- دادههای زمانی باید به ترتیب زمانی (از قدیمیترین به جدیدترین) مرتب باشند
|
||||||
|
- دادههای مرتبسازی شده (مثل پرفروشترین) باید به ترتیب مناسب مرتب باشند
|
||||||
|
|
||||||
|
6. **مقدار پیشفرض:**
|
||||||
|
|
||||||
|
- در صورت نبود داده، آرایه خالی `[]` یا `null` برگردانده شود
|
||||||
|
- مقادیر عددی در صورت نبود داده باید `0` باشند
|
||||||
|
|
||||||
|
7. **خطاها:**
|
||||||
|
|
||||||
|
- در صورت خطا، کد HTTP مناسب برگردانده شود
|
||||||
|
- پیام خطا به فارسی و واضح باشد
|
||||||
|
|
||||||
|
8. **Performance:**
|
||||||
|
- برای بهینهسازی، میتوان از کش استفاده کرد
|
||||||
|
- Queryها باید بهینه باشند تا زمان پاسخگویی کم باشد
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اولویت پیادهسازی:
|
||||||
|
|
||||||
|
1. **اولویت بالا:**
|
||||||
|
|
||||||
|
- API آمار کلی داشبورد (#1)
|
||||||
|
- API فروش ماهانه (#2)
|
||||||
|
- API کاربران اخیر (#4)
|
||||||
|
- API سفارشهای اخیر (#6)
|
||||||
|
|
||||||
|
2. **اولویت متوسط:**
|
||||||
|
|
||||||
|
- API روند رشد (#3)
|
||||||
|
- API محصولات پرفروش (#7)
|
||||||
|
- API آمار مقایسهای (#8)
|
||||||
|
|
||||||
|
3. **اولویت پایین:**
|
||||||
|
- API توزیع دستگاههای کاربری (#5) - در صورت وجود داده
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مثال استفاده:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# دریافت آمار کلی
|
||||||
|
curl -X GET "https://api.example.com/api/v1/admin/dashboard/stats" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# دریافت فروش 6 ماه گذشته
|
||||||
|
curl -X GET "https://api.example.com/api/v1/admin/dashboard/monthly-sales?months=6" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# دریافت کاربران اخیر
|
||||||
|
curl -X GET "https://api.example.com/api/v1/admin/dashboard/recent-users?limit=10" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**ممنون میشوم اگر این APIها را در اسرع وقت پیادهسازی کنید تا بتوانیم داشبورد را کامل کنیم.** 🙏
|
||||||
|
|
||||||
|
**در صورت نیاز به توضیحات بیشتر یا تغییرات، لطفاً اطلاع دهید.**
|
||||||
16
src/App.tsx
16
src/App.tsx
|
|
@ -93,9 +93,11 @@ const ProtectedRoute = ({ children }: { children: any }) => {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<Layout>
|
||||||
<LoadingSpinner />
|
<div className="flex items-center justify-center py-12">
|
||||||
</div>
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,9 +208,11 @@ const App = () => {
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<Layout>
|
||||||
<LoadingSpinner />
|
<div className="flex items-center justify-center py-12">
|
||||||
</div>
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
}>
|
}>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,10 @@ export const StatsCard = ({
|
||||||
const isNegative = change && change < 0;
|
const isNegative = change && change < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-3 sm:p-4 lg:p-6 animate-fade-in">
|
<div className="card p-4 sm:p-5 lg:p-6 animate-fade-in">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className={`p-2 sm:p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
|
<div className={`p-3 sm:p-4 rounded-xl ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue} shadow-sm`}>
|
||||||
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
|
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
<header className="bg-white dark:bg-gray-800 shadow-md 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 justify-between px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center space-x-4 space-x-reverse">
|
<div className="flex items-center space-x-4 space-x-reverse">
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export const Layout = () => {
|
||||||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="min-h-full">
|
<div className="min-h-full py-6 px-4 sm:px-6 lg:px-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -185,14 +185,58 @@ interface SidebarProps {
|
||||||
|
|
||||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
|
const location = useLocation();
|
||||||
|
const [expandedItems, setExpandedItems] = React.useState<string[]>(() => {
|
||||||
|
// Load from localStorage on mount
|
||||||
|
const saved = localStorage.getItem('sidebar_expanded_items');
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-expand menu items based on current route
|
||||||
|
React.useEffect(() => {
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
|
||||||
|
setExpandedItems(prev => {
|
||||||
|
const itemsToExpand: string[] = [];
|
||||||
|
|
||||||
|
menuItems.forEach(item => {
|
||||||
|
if (item.children) {
|
||||||
|
// Check if any child matches current path
|
||||||
|
const hasActiveChild = item.children.some(child => {
|
||||||
|
if (child.path) {
|
||||||
|
if (child.exact) {
|
||||||
|
return currentPath === child.path;
|
||||||
|
}
|
||||||
|
return currentPath.startsWith(child.path);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasActiveChild && !prev.includes(item.title)) {
|
||||||
|
itemsToExpand.push(item.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (itemsToExpand.length > 0) {
|
||||||
|
return [...prev, ...itemsToExpand];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Save to localStorage whenever expandedItems changes
|
||||||
|
localStorage.setItem('sidebar_expanded_items', JSON.stringify(expandedItems));
|
||||||
|
}, [expandedItems]);
|
||||||
|
|
||||||
const toggleExpanded = (title: string) => {
|
const toggleExpanded = (title: string) => {
|
||||||
setExpandedItems(prev =>
|
setExpandedItems(prev => {
|
||||||
prev.includes(title)
|
const newItems = prev.includes(title)
|
||||||
? prev.filter(item => item !== title)
|
? prev.filter(item => item !== title)
|
||||||
: [...prev, title]
|
: [...prev, title];
|
||||||
);
|
return newItems;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMenuItem = (item: MenuItem, depth = 0) => {
|
const renderMenuItem = (item: MenuItem, depth = 0) => {
|
||||||
|
|
@ -205,8 +249,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
<div key={item.title} className="space-y-1">
|
<div key={item.title} className="space-y-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpanded(item.title)}
|
onClick={() => toggleExpanded(item.title)}
|
||||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors
|
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200
|
||||||
text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700`}
|
text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-sm`}
|
||||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||||
>
|
>
|
||||||
<item.icon className="ml-3 h-5 w-5" />
|
<item.icon className="ml-3 h-5 w-5" />
|
||||||
|
|
@ -237,9 +281,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${isActive
|
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${isActive
|
||||||
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400'
|
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white'
|
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white hover:shadow-sm'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||||
|
|
@ -276,35 +320,35 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
w-64 transform transition-transform duration-300 ease-in-out
|
w-64 transform transition-transform duration-300 ease-in-out
|
||||||
lg:translate-x-0 lg:block
|
lg:translate-x-0 lg:block
|
||||||
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
|
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
|
||||||
flex flex-col bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700
|
flex flex-col h-screen bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-lg lg:shadow-none
|
||||||
`}>
|
`}>
|
||||||
{/* Mobile close button */}
|
{/* Mobile close button */}
|
||||||
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
<SectionTitle>
|
<SectionTitle>
|
||||||
پنل مدیریت
|
پنل مدیریت
|
||||||
</SectionTitle>
|
</SectionTitle>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo - desktop only */}
|
{/* Logo - desktop only */}
|
||||||
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700">
|
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
<SectionTitle>
|
<SectionTitle>
|
||||||
پنل مدیریت
|
پنل مدیریت
|
||||||
</SectionTitle>
|
</SectionTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto">
|
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0">
|
||||||
{menuItems.map(item => renderMenuItem(item))}
|
{menuItems.map(item => renderMenuItem(item))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0">
|
||||||
<div className="flex items-center space-x-3 space-x-reverse">
|
<div className="flex items-center space-x-3 space-x-reverse">
|
||||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||||
<span className="text-sm font-medium text-white">
|
<span className="text-sm font-medium text-white">
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const Button = ({
|
||||||
className = '',
|
className = '',
|
||||||
...rest
|
...rest
|
||||||
}: ButtonProps) => {
|
}: 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 baseClasses = 'inline-flex items-center justify-center rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-sm hover:shadow-md';
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
|
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ export const Modal = ({
|
||||||
|
|
||||||
<div className={`
|
<div className={`
|
||||||
relative w-full ${sizeClasses[size]}
|
relative w-full ${sizeClasses[size]}
|
||||||
bg-white dark:bg-gray-800 rounded-lg shadow-xl
|
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
|
||||||
transform transition-all
|
transform transition-all border border-gray-200 dark:border-gray-700
|
||||||
`}>
|
`}>
|
||||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<SectionSubtitle>{title}</SectionSubtitle>
|
<SectionSubtitle>{title}</SectionSubtitle>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface LabelProps extends TypographyProps {
|
||||||
|
|
||||||
// Page Headers
|
// Page Headers
|
||||||
export const PageTitle = ({ children, className = '' }: TypographyProps) => (
|
export const PageTitle = ({ children, className = '' }: TypographyProps) => (
|
||||||
<h1 className={`text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-gray-100 ${className}`}>
|
<h1 className={`text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6 ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</h1>
|
</h1>
|
||||||
);
|
);
|
||||||
|
|
@ -109,7 +109,7 @@ export const FormHeader = ({ title, subtitle, backButton, actions, className = '
|
||||||
|
|
||||||
// Page Container with consistent mobile spacing
|
// Page Container with consistent mobile spacing
|
||||||
export const PageContainer = ({ children, className = '' }: TypographyProps) => (
|
export const PageContainer = ({ children, className = '' }: TypographyProps) => (
|
||||||
<div className={`p-4 sm:p-6 lg:p-8 space-y-4 sm:space-y-6 max-w-none ${className}`}>
|
<div className={`space-y-6 max-w-none ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ export const ThemeProvider = ({ children }: { children: any }) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
|
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');
|
const initialTheme = savedTheme || 'light';
|
||||||
setMode(initialTheme);
|
setMode(initialTheme);
|
||||||
|
|
||||||
if (initialTheme === 'dark') {
|
if (initialTheme === 'dark') {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
|
@apply bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 transition-shadow duration-200 hover:shadow-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { Modal } from '../components/ui/Modal';
|
||||||
import { Pagination } from '../components/ui/Pagination';
|
import { Pagination } from '../components/ui/Pagination';
|
||||||
import { UserForm } from '../components/forms/UserForm';
|
import { UserForm } from '../components/forms/UserForm';
|
||||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||||
import { LoadingSpinner } from '../components/ui/LoadingSpinner';
|
|
||||||
import { TableColumn } from '../types';
|
import { TableColumn } from '../types';
|
||||||
import { UserFormData } from '../utils/validationSchemas';
|
import { UserFormData } from '../utils/validationSchemas';
|
||||||
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
|
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
|
||||||
|
|
@ -211,7 +210,9 @@ const Users = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner />
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<Table columns={columns} data={[]} loading={true} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react';
|
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react';
|
||||||
import { Button } from '../../../components/ui/Button';
|
import { Button } from '../../../components/ui/Button';
|
||||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
|
||||||
import { useAdminUser } from '../core/_hooks';
|
import { useAdminUser } from '../core/_hooks';
|
||||||
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
||||||
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
||||||
|
|
@ -12,7 +11,47 @@ const AdminUserDetailPage = () => {
|
||||||
|
|
||||||
const { data: user, isLoading, error } = useAdminUser(id);
|
const { data: user, isLoading, error } = useAdminUser(id);
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{[...Array(4)].map((_, j) => (
|
||||||
|
<div key={j}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, j) => (
|
||||||
|
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات کاربر</div>;
|
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات کاربر</div>;
|
||||||
if (!user) return <div>کاربر یافت نشد</div>;
|
if (!user) return <div>کاربر یافت نشد</div>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { usePermissions } from '../../permissions/core/_hooks';
|
||||||
import { useRoles } from '../../roles/core/_hooks';
|
import { useRoles } from '../../roles/core/_hooks';
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||||
|
|
@ -134,9 +133,19 @@ const AdminUserFormPage = () => {
|
||||||
|
|
||||||
if (isEdit && isLoadingUser) {
|
if (isEdit && isLoadingUser) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ArrowRight, FolderOpen } from 'lucide-react';
|
import { ArrowRight, FolderOpen } from 'lucide-react';
|
||||||
import { Button } from '../../../components/ui/Button';
|
import { Button } from '../../../components/ui/Button';
|
||||||
import { Input } from '../../../components/ui/Input';
|
import { Input } from '../../../components/ui/Input';
|
||||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
|
||||||
import { FileUploader } from '../../../components/ui/FileUploader';
|
import { FileUploader } from '../../../components/ui/FileUploader';
|
||||||
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
|
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
|
@ -116,9 +115,19 @@ const CategoryFormPage = () => {
|
||||||
|
|
||||||
if (isEdit && isLoadingCategory) {
|
if (isEdit && isLoadingCategory) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { useDiscountCode, useCreateDiscountCode, useUpdateDiscountCode } from '.
|
||||||
import { CreateDiscountCodeRequest } from '../core/_models';
|
import { CreateDiscountCodeRequest } from '../core/_models';
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
||||||
import { SingleSelectAutocomplete } from "@/components/ui/SingleSelectAutocomplete";
|
import { SingleSelectAutocomplete } from "@/components/ui/SingleSelectAutocomplete";
|
||||||
import { JalaliDateTimePicker } from "@/components/ui/JalaliDateTimePicker";
|
import { JalaliDateTimePicker } from "@/components/ui/JalaliDateTimePicker";
|
||||||
|
|
@ -258,7 +257,23 @@ const DiscountCodeFormPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && dcLoading) return <LoadingSpinner />;
|
if (isEdit && dcLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
const isLoading = creating || updating;
|
const isLoading = creating || updating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import { FileUploader } from "@/components/ui/FileUploader";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { useLandingHero, useUpdateLandingHero } from "./core/_hooks";
|
import { useLandingHero, useUpdateLandingHero } from "./core/_hooks";
|
||||||
import { LandingHeroData, HeroImage } from "./core/_models";
|
import { LandingHeroData, HeroImage } from "./core/_models";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { PlusCircle, Trash2, Save } from "lucide-react";
|
import { PlusCircle, Trash2, Save } from "lucide-react";
|
||||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { PageContainer } from "@/components/ui/Typography";
|
||||||
|
|
||||||
const heroImageSchema = yup.object({
|
const heroImageSchema = yup.object({
|
||||||
alt_text: yup.string().required("متن ALT الزامی است"),
|
alt_text: yup.string().required("متن ALT الزامی است"),
|
||||||
|
|
@ -92,9 +92,19 @@ export const HeroSliderPage = () => {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useOrder, useUpdateOrderStatus } from '../core/_hooks';
|
||||||
import { OrderStatus } from '../core/_models';
|
import { OrderStatus } from '../core/_models';
|
||||||
import { useShippingMethods } from '@/pages/shipping-methods/core/_hooks';
|
import { useShippingMethods } from '@/pages/shipping-methods/core/_hooks';
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
||||||
import {
|
import {
|
||||||
|
|
@ -117,7 +116,41 @@ const OrderDetailPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64"></div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(4)].map((_, j) => (
|
||||||
|
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, j) => (
|
||||||
|
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error || !order) {
|
if (error || !order) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { CreditCard } from 'lucide-react';
|
||||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|
||||||
import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks';
|
import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks';
|
||||||
import { persianToEnglish } from '@/utils/numberUtils';
|
import { persianToEnglish } from '@/utils/numberUtils';
|
||||||
|
|
||||||
|
|
@ -141,9 +140,19 @@ const CardFormPage = () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex justify-center items-center h-64">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CreditCard, Loader2 } from 'lucide-react';
|
import { CreditCard, Loader2 } from 'lucide-react';
|
||||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|
||||||
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
|
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
|
||||||
import { IPGStatus, IPG_LABELS } from '../core/_models';
|
import { IPGStatus, IPG_LABELS } from '../core/_models';
|
||||||
|
|
||||||
|
|
@ -64,8 +63,32 @@ const IPGListPage = () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<LoadingSpinner />
|
<div>
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-56 mb-2 animate-pulse"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-64 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
|
||||||
|
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-11 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { usePermission, useCreatePermission, useUpdatePermission } from '../core
|
||||||
import { PermissionFormData } from '../core/_models';
|
import { PermissionFormData } from '../core/_models';
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||||
|
|
||||||
|
|
@ -81,9 +80,19 @@ const PermissionFormPage = () => {
|
||||||
|
|
||||||
if (isEdit && isLoadingPermission) {
|
if (isEdit && isLoadingPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { useProductOption, useCreateProductOption, useUpdateProductOption } from
|
||||||
import { ProductOptionFormData } from '../core/_models';
|
import { ProductOptionFormData } from '../core/_models';
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { FileUploader } from "@/components/ui/FileUploader";
|
import { FileUploader } from "@/components/ui/FileUploader";
|
||||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||||
import { ArrowRight, Plus, Trash2 } from "lucide-react";
|
import { ArrowRight, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
@ -142,9 +141,19 @@ const ProductOptionFormPage = () => {
|
||||||
|
|
||||||
if (isLoadingOption) {
|
if (isLoadingOption) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||||
import { Modal } from '../../../components/ui/Modal';
|
import { Modal } from '../../../components/ui/Modal';
|
||||||
import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign, Hash, Layers, Settings } from 'lucide-react';
|
import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign, Hash, Layers, Settings } from 'lucide-react';
|
||||||
import { Button } from '../../../components/ui/Button';
|
import { Button } from '../../../components/ui/Button';
|
||||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
|
||||||
import { useProduct } from '../core/_hooks';
|
import { useProduct } from '../core/_hooks';
|
||||||
import { PRODUCT_TYPE_LABELS } from '../core/_models';
|
import { PRODUCT_TYPE_LABELS } from '../core/_models';
|
||||||
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
|
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
|
||||||
|
|
@ -25,7 +24,44 @@ const ProductDetailPage = () => {
|
||||||
|
|
||||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: 'image' | 'video' } | null>(null);
|
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: 'image' | 'video' } | null>(null);
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(4)].map((_, j) => (
|
||||||
|
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, j) => (
|
||||||
|
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>;
|
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>;
|
||||||
if (!product) return <div>محصول یافت نشد</div>;
|
if (!product) return <div>محصول یافت نشد</div>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { ProductFormData, ProductImage, ProductVariantFormData, PRODUCT_TYPES, P
|
||||||
import { MultiSelectAutocomplete } from "@/components/ui/MultiSelectAutocomplete";
|
import { MultiSelectAutocomplete } from "@/components/ui/MultiSelectAutocomplete";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { FileUploader } from "@/components/ui/FileUploader";
|
import { FileUploader } from "@/components/ui/FileUploader";
|
||||||
import { VariantManager } from "@/components/ui/VariantManager";
|
import { VariantManager } from "@/components/ui/VariantManager";
|
||||||
import { ArrowRight, X } from "lucide-react";
|
import { ArrowRight, X } from "lucide-react";
|
||||||
|
|
@ -420,9 +419,19 @@ const ProductFormPage = () => {
|
||||||
|
|
||||||
if (isEdit && isLoadingProduct) {
|
if (isEdit && isLoadingProduct) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(8)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useProducts, useDeleteProduct } from '../core/_hooks';
|
||||||
import { useCategories } from '../../categories/core/_hooks';
|
import { useCategories } from '../../categories/core/_hooks';
|
||||||
import { Product } from '../core/_models';
|
import { Product } from '../core/_models';
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { PageContainer } from "@/components/ui/Typography";
|
||||||
import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
|
import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { persianToEnglish } from '../../../utils/numberUtils';
|
import { persianToEnglish } from '../../../utils/numberUtils';
|
||||||
|
|
@ -152,335 +152,355 @@ const ProductsListPage = () => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<PageContainer>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری محصولات</p>
|
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری محصولات</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<PageContainer>
|
||||||
{/* Header */}
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
{/* Header */}
|
||||||
<div>
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
<div>
|
||||||
<Package className="h-6 w-6" />
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
مدیریت محصولات
|
<Package className="h-6 w-6" />
|
||||||
</h1>
|
مدیریت محصولات
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
</h1>
|
||||||
مدیریت محصولات، قیمتها و موجودی
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
</p>
|
مدیریت محصولات، قیمتها و موجودی
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||||
|
title="محصول جدید"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
|
||||||
title="محصول جدید"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
{isLoading ? (
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 animate-pulse">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
{[...Array(4)].map((_, i) => (
|
||||||
جستجو
|
<div key={i}>
|
||||||
</label>
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
||||||
<input
|
{i === 3 ? (
|
||||||
type="text"
|
<div className="flex gap-2">
|
||||||
placeholder="جستجو در نام محصول..."
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex-1"></div>
|
||||||
value={filters.search}
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex-1"></div>
|
||||||
onChange={handleSearchChange}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
دستهبندی
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filters.category_id}
|
|
||||||
onChange={handleCategoryChange}
|
|
||||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<option value="">همه دستهبندیها</option>
|
|
||||||
{(categories || []).map((category) => (
|
|
||||||
<option key={category.id} value={category.id}>
|
|
||||||
{category.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
وضعیت
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filters.status}
|
|
||||||
onChange={handleStatusChange}
|
|
||||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<option value="">همه وضعیتها</option>
|
|
||||||
<option value="active">فعال</option>
|
|
||||||
<option value="inactive">غیرفعال</option>
|
|
||||||
<option value="draft">پیشنویس</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
محدوده قیمت
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="حداقل (مثال: ۱۰۰۰۰)"
|
|
||||||
value={filters.min_price}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
setFilters(prev => ({ ...prev, min_price: converted, page: 1 }));
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="حداکثر (مثال: ۵۰۰۰۰۰)"
|
|
||||||
value={filters.max_price}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
setFilters(prev => ({ ...prev, max_price: converted, page: 1 }));
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Products Table */}
|
|
||||||
{isLoading ? (
|
|
||||||
<ProductsTableSkeleton />
|
|
||||||
) : (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
{/* Desktop Table */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
محصول
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
قیمت
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
دستهبندی
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
وضعیت
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
عملیات
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{products.map((product: Product) => (
|
|
||||||
<tr key={product.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{getFirstImageUrl(product) ? (
|
|
||||||
<img
|
|
||||||
src={getFirstImageUrl(product) as string}
|
|
||||||
alt={product.name}
|
|
||||||
className="w-10 h-10 object-cover rounded"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
|
||||||
<Image className="h-5 w-5 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{product.name}
|
|
||||||
</div>
|
|
||||||
{product.sku && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
SKU: {product.sku}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{formatPrice(product.price || 0)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{product.category?.name || 'بدون دستهبندی'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{getStatusBadge(product.status || '')}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleView(product.id)}
|
|
||||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
|
||||||
title="مشاهده"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(product.id)}
|
|
||||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
|
||||||
title="ویرایش"
|
|
||||||
>
|
|
||||||
<Edit3 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteProductId(product.id.toString())}
|
|
||||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
|
||||||
title="حذف"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Cards */}
|
|
||||||
<div className="md:hidden p-4 space-y-4">
|
|
||||||
{products.map((product: Product) => (
|
|
||||||
<div key={product.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="flex gap-3 mb-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{getFirstImageUrl(product) ? (
|
|
||||||
<img
|
|
||||||
src={getFirstImageUrl(product) as string}
|
|
||||||
alt={product.name}
|
|
||||||
className="w-12 h-12 object-cover rounded"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
|
||||||
<Image className="h-6 w-6 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{formatPrice(product.price || 0)}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
{getStatusBadge(product.status || '')}
|
|
||||||
{product.category && (
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{product.category.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<button
|
|
||||||
onClick={() => handleView(product.id)}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
مشاهده
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(product.id)}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
|
||||||
>
|
|
||||||
<Edit3 className="h-3 w-3" />
|
|
||||||
ویرایش
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteProductId(product.id.toString())}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
حذف
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
جستجو
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجو در نام محصول..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
</div>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
دستهبندی
|
||||||
{/* Empty State */}
|
</label>
|
||||||
{(!products || products.length === 0) && !isLoading && (
|
<select
|
||||||
<div className="text-center py-12">
|
value={filters.category_id}
|
||||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
onChange={handleCategoryChange}
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
محصولی موجود نیست
|
>
|
||||||
</h3>
|
<option value="">همه دستهبندیها</option>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
{(categories || []).map((category) => (
|
||||||
برای شروع، اولین محصول خود را ایجاد کنید.
|
<option key={category.id} value={category.id}>
|
||||||
</p>
|
{category.name}
|
||||||
<div className="mt-6">
|
</option>
|
||||||
<Button onClick={handleCreate} className="flex items-center gap-2 mx-auto">
|
))}
|
||||||
<Plus className="h-4 w-4" />
|
</select>
|
||||||
ایجاد محصول جدید
|
</div>
|
||||||
</Button>
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
وضعیت
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<option value="">همه وضعیتها</option>
|
||||||
|
<option value="active">فعال</option>
|
||||||
|
<option value="inactive">غیرفعال</option>
|
||||||
|
<option value="draft">پیشنویس</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
محدوده قیمت
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="حداقل (مثال: ۱۰۰۰۰)"
|
||||||
|
value={filters.min_price}
|
||||||
|
onChange={(e) => {
|
||||||
|
const converted = persianToEnglish(e.target.value);
|
||||||
|
setFilters(prev => ({ ...prev, min_price: converted, page: 1 }));
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="حداکثر (مثال: ۵۰۰۰۰۰)"
|
||||||
|
value={filters.max_price}
|
||||||
|
onChange={(e) => {
|
||||||
|
const converted = persianToEnglish(e.target.value);
|
||||||
|
setFilters(prev => ({ ...prev, max_price: converted, page: 1 }));
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Products Table */}
|
||||||
<Pagination
|
{isLoading ? (
|
||||||
currentPage={currentPage}
|
<ProductsTableSkeleton />
|
||||||
totalPages={totalPages}
|
) : (
|
||||||
itemsPerPage={perPage}
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
totalItems={total}
|
{/* Desktop Table */}
|
||||||
onPageChange={(page) => setFilters(prev => ({ ...prev, page }))}
|
<div className="hidden md:block">
|
||||||
/>
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
محصول
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
قیمت
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
دستهبندی
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
وضعیت
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
عملیات
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{products.map((product: Product) => (
|
||||||
|
<tr key={product.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{getFirstImageUrl(product) ? (
|
||||||
|
<img
|
||||||
|
src={getFirstImageUrl(product) as string}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-10 h-10 object-cover rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
||||||
|
<Image className="h-5 w-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{product.name}
|
||||||
|
</div>
|
||||||
|
{product.sku && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
SKU: {product.sku}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{formatPrice(product.price || 0)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{product.category?.name || 'بدون دستهبندی'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{getStatusBadge(product.status || '')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleView(product.id)}
|
||||||
|
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
title="مشاهده"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(product.id)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
title="ویرایش"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteProductId(product.id.toString())}
|
||||||
|
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
title="حذف"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Mobile Cards */}
|
||||||
<Modal
|
<div className="md:hidden p-4 space-y-4">
|
||||||
isOpen={!!deleteProductId}
|
{products.map((product: Product) => (
|
||||||
onClose={() => setDeleteProductId(null)}
|
<div key={product.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
title="حذف محصول"
|
<div className="flex gap-3 mb-3">
|
||||||
>
|
<div className="flex-shrink-0">
|
||||||
<div className="space-y-4">
|
{getFirstImageUrl(product) ? (
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<img
|
||||||
آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخهها و تصاویر حذف خواهد شد.
|
src={getFirstImageUrl(product) as string}
|
||||||
</p>
|
alt={product.name}
|
||||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
className="w-12 h-12 object-cover rounded"
|
||||||
<Button
|
/>
|
||||||
variant="secondary"
|
) : (
|
||||||
onClick={() => setDeleteProductId(null)}
|
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
||||||
disabled={isDeleting}
|
<Image className="h-6 w-6 text-gray-500" />
|
||||||
>
|
</div>
|
||||||
انصراف
|
)}
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
<div className="flex-1">
|
||||||
variant="danger"
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
onClick={handleDeleteConfirm}
|
{product.name}
|
||||||
loading={isDeleting}
|
</h3>
|
||||||
>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
حذف
|
{formatPrice(product.price || 0)}
|
||||||
</Button>
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{getStatusBadge(product.status || '')}
|
||||||
|
{product.category && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{product.category.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleView(product.id)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
مشاهده
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(product.id)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-3 w-3" />
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteProductId(product.id.toString())}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{(!products || products.length === 0) && !isLoading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
محصولی موجود نیست
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
برای شروع، اولین محصول خود را ایجاد کنید.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button onClick={handleCreate} className="flex items-center gap-2 mx-auto">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
ایجاد محصول جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Modal>
|
|
||||||
</div>
|
{/* Pagination */}
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
itemsPerPage={perPage}
|
||||||
|
totalItems={total}
|
||||||
|
onPageChange={(page) => setFilters(prev => ({ ...prev, page }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteProductId}
|
||||||
|
onClose={() => setDeleteProductId(null)}
|
||||||
|
title="حذف محصول"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخهها و تصاویر حذف خواهد شد.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setDeleteProductId(null)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
loading={isDeleting}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText } from 'lucide-react';
|
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText } from 'lucide-react';
|
||||||
import { Button } from '../../../components/ui/Button';
|
import { Button } from '../../../components/ui/Button';
|
||||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
|
||||||
import { useRole } from '../core/_hooks';
|
import { useRole } from '../core/_hooks';
|
||||||
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
||||||
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
||||||
|
|
@ -12,7 +11,50 @@ const RoleDetailPage = () => {
|
||||||
|
|
||||||
const { data: role, isLoading, error } = useRole(id);
|
const { data: role, isLoading, error } = useRole(id);
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6"></div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||||
|
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(2)].map((_, j) => (
|
||||||
|
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات نقش</div>;
|
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات نقش</div>;
|
||||||
if (!role) return <div>نقش یافت نشد</div>;
|
if (!role) return <div>نقش یافت نشد</div>;
|
||||||
|
|
||||||
|
|
@ -170,7 +212,7 @@ const RoleDetailPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { useRole, useCreateRole, useUpdateRole } from '../core/_hooks';
|
||||||
import { RoleFormData } from '../core/_models';
|
import { RoleFormData } from '../core/_models';
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { ArrowRight, Shield } from "lucide-react";
|
import { ArrowRight, Shield } from "lucide-react";
|
||||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||||
|
|
||||||
|
|
@ -70,7 +69,21 @@ const RoleFormPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && roleLoading) {
|
if (isEdit && roleLoading) {
|
||||||
return <LoadingSpinner />;
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoading = creating || updating;
|
const isLoading = creating || updating;
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useRole, useRolePermissions, usePermissions, useAssignPermission, useRemovePermission } from "../core/_hooks";
|
import { useRole, useRolePermissions, usePermissions, useAssignPermission, useRemovePermission } from "../core/_hooks";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { Permission } from "@/types/auth";
|
import { Permission } from "@/types/auth";
|
||||||
import { ArrowRight, Plus, Trash2, Check, Shield } from "lucide-react";
|
import { ArrowRight, Plus, Trash2, Check, Shield } from "lucide-react";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { PageContainer } from "@/components/ui/Typography";
|
||||||
|
|
||||||
const RolePermissionsPage = () => {
|
const RolePermissionsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -56,11 +56,33 @@ const RolePermissionsPage = () => {
|
||||||
|
|
||||||
const isLoading = roleLoading || permissionsLoading;
|
const isLoading = roleLoading || permissionsLoading;
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64"></div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<div key={i} className="card">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
{[...Array(5)].map((_, j) => (
|
||||||
|
<div key={j} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!role) return <div className="text-red-600">نقش یافت نشد</div>;
|
if (!role) return <div className="text-red-600">نقش یافت نشد</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<PageContainer>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -93,8 +115,10 @@ const RolePermissionsPage = () => {
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{permissionsLoading ? (
|
{permissionsLoading ? (
|
||||||
<div className="flex justify-center">
|
<div className="space-y-3 animate-pulse">
|
||||||
<LoadingSpinner />
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -143,8 +167,10 @@ const RolePermissionsPage = () => {
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{allPermissionsLoading ? (
|
{allPermissionsLoading ? (
|
||||||
<div className="flex justify-center">
|
<div className="space-y-3 animate-pulse">
|
||||||
<LoadingSpinner />
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -214,7 +240,7 @@ const RolePermissionsPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useCreateShippingMethod, useShippingMethod, useUpdateShippingMethod } f
|
||||||
import { ShippingOpenHour } from '../core/_models';
|
import { ShippingOpenHour } from '../core/_models';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|
||||||
import { TagInput } from '@/components/ui/TagInput';
|
import { TagInput } from '@/components/ui/TagInput';
|
||||||
import { Truck } from 'lucide-react';
|
import { Truck } from 'lucide-react';
|
||||||
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
|
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
|
||||||
|
|
@ -107,9 +106,19 @@ const ShippingMethodFormPage = () => {
|
||||||
|
|
||||||
if (isEdit && isLoading) {
|
if (isEdit && isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[200px] flex items-center justify-center">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { PageContainer } from '@/components/ui/Typography';
|
||||||
import { Plus, Edit3, Trash2, Truck } from 'lucide-react';
|
import { Plus, Edit3, Trash2, Truck } from 'lucide-react';
|
||||||
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
|
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
|
||||||
import { ShippingMethod } from '../core/_models';
|
import { ShippingMethod } from '../core/_models';
|
||||||
|
|
@ -28,24 +28,59 @@ const ShippingMethodsListPage = () => {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 flex justify-center">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6">
|
||||||
</div>
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
|
<div>
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64 mb-2 animate-pulse"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-12 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
{[...Array(7)].map((_, i) => (
|
||||||
|
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<tr key={i} className="animate-pulse">
|
||||||
|
{[...Array(7)].map((_, j) => (
|
||||||
|
<td key={j} className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<PageContainer>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری روشهای ارسال</p>
|
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری روشهای ارسال</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<PageContainer>
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
|
@ -142,7 +177,8 @@ const ShippingMethodsListPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
import { TicketStatus } from "../core/_models";
|
import { TicketStatus } from "../core/_models";
|
||||||
import { PageContainer, PageTitle, SectionTitle, Label } from "@/components/ui/Typography";
|
import { PageContainer, PageTitle, SectionTitle, Label } from "@/components/ui/Typography";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|
||||||
import { FileUploader } from "@/components/ui/FileUploader";
|
import { FileUploader } from "@/components/ui/FileUploader";
|
||||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
|
|
@ -155,9 +154,19 @@ const TicketDetailPage = () => {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<PageContainer>
|
||||||
<LoadingSpinner />
|
<div className="space-y-6 animate-pulse">
|
||||||
</div>
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,137 +132,178 @@ const TicketsListPage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="space-y-6">
|
||||||
<div>
|
{/* Header */}
|
||||||
<PageTitle className="flex items-center gap-2">
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<MessageSquare className="h-6 w-6" />
|
|
||||||
مدیریت تیکتها
|
|
||||||
</PageTitle>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{data?.total || 0} تیکت ثبت شده
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => navigate("/tickets/config")}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
تنظیمات تیکت
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4 space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute right-3 top-3 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
value={filters.search || ""}
|
|
||||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
|
||||||
placeholder="جستجو در عنوان یا شماره تیکت"
|
|
||||||
className="w-full pr-9 pl-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label="شناسه مسئول"
|
|
||||||
type="number"
|
|
||||||
value={filters.assigned_to || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleFilterChange(
|
|
||||||
"assigned_to",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<PageTitle className="flex items-center gap-2">
|
||||||
وضعیت
|
<MessageSquare className="h-6 w-6" />
|
||||||
</label>
|
مدیریت تیکتها
|
||||||
<select
|
</PageTitle>
|
||||||
value={filters.status_id || ""}
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
onChange={(e) =>
|
{data?.total || 0} تیکت ثبت شده
|
||||||
handleFilterChange(
|
</p>
|
||||||
"status_id",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<option value="">همه وضعیتها</option>
|
|
||||||
{statuses?.map((status) => (
|
|
||||||
<option key={status.id} value={status.id}>
|
|
||||||
{status.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex gap-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
دپارتمان
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filters.department_id || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleFilterChange(
|
|
||||||
"department_id",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<option value="">همه دپارتمانها</option>
|
|
||||||
{departments?.map((department) => (
|
|
||||||
<option key={department.id} value={department.id}>
|
|
||||||
{department.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full flex items-center justify-center gap-2"
|
className="flex items-center gap-2"
|
||||||
onClick={() =>
|
onClick={() => navigate("/tickets/config")}
|
||||||
setFilters({
|
|
||||||
page: 1,
|
|
||||||
limit: 20,
|
|
||||||
search: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Filter className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
پاک کردن فیلترها
|
تنظیمات تیکت
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Filters */}
|
||||||
<Table columns={columns} data={[]} loading />
|
<div className="card p-4 sm:p-6">
|
||||||
) : !data?.tickets || data.tickets.length === 0 ? (
|
{isLoading ? (
|
||||||
<div className="card p-8 text-center text-gray-500 dark:text-gray-400">
|
<div className="space-y-4 animate-pulse">
|
||||||
تیکتی برای نمایش وجود ندارد
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5 text-gray-500" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
search: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 ml-2" />
|
||||||
|
پاک کردن فیلترها
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
جستجو
|
||||||
|
</label>
|
||||||
|
<Search className="absolute right-3 top-10 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={filters.search || ""}
|
||||||
|
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||||
|
placeholder="جستجو در عنوان یا شماره تیکت"
|
||||||
|
className="w-full pr-9 pl-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="شناسه مسئول"
|
||||||
|
type="number"
|
||||||
|
value={filters.assigned_to || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange(
|
||||||
|
"assigned_to",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
وضعیت
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.status_id || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange(
|
||||||
|
"status_id",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<option value="">همه وضعیتها</option>
|
||||||
|
{statuses?.map((status) => (
|
||||||
|
<option key={status.id} value={status.id}>
|
||||||
|
{status.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
دپارتمان
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.department_id || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange(
|
||||||
|
"department_id",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<option value="">همه دپارتمانها</option>
|
||||||
|
{departments?.map((department) => (
|
||||||
|
<option key={department.id} value={department.id}>
|
||||||
|
{department.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
{/* Table */}
|
||||||
<Table columns={columns} data={data.tickets as any[]} />
|
{isLoading ? (
|
||||||
<Pagination
|
<Table columns={columns} data={[]} loading />
|
||||||
currentPage={filters.page || 1}
|
) : !data?.tickets || data.tickets.length === 0 ? (
|
||||||
totalPages={Math.max(
|
<div className="card p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
1,
|
تیکتی برای نمایش وجود ندارد
|
||||||
Math.ceil((data.total || 0) / (filters.limit || 20))
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table columns={columns} data={data.tickets as any[]} />
|
||||||
|
{data.total > 0 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={filters.page || 1}
|
||||||
|
totalPages={Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((data.total || 0) / (filters.limit || 20))
|
||||||
|
)}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
itemsPerPage={filters.limit || 20}
|
||||||
|
totalItems={data.total || 0}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
onPageChange={handlePageChange}
|
</>
|
||||||
itemsPerPage={filters.limit || 20}
|
)}
|
||||||
totalItems={data.total || 0}
|
</div>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { englishToPersian } from '../../../utils/numberUtils';
|
||||||
import { PageContainer } from '../../../components/ui/Typography';
|
import { PageContainer } from '../../../components/ui/Typography';
|
||||||
import { Button } from '../../../components/ui/Button';
|
import { Button } from '../../../components/ui/Button';
|
||||||
import { Modal } from '../../../components/ui/Modal';
|
import { Modal } from '../../../components/ui/Modal';
|
||||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
|
||||||
|
|
||||||
const UserAdminDetailPage: React.FC = () => {
|
const UserAdminDetailPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -56,8 +55,40 @@ const UserAdminDetailPage: React.FC = () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex justify-center items-center py-12">
|
<div className="space-y-6 animate-pulse">
|
||||||
<LoadingSpinner />
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{[...Array(4)].map((_, j) => (
|
||||||
|
<div key={j}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, j) => (
|
||||||
|
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { CreateUserRequest, UpdateUserRequest } from '../core/_models';
|
||||||
import { PageContainer } from '../../../components/ui/Typography';
|
import { PageContainer } from '../../../components/ui/Typography';
|
||||||
import { Button } from '../../../components/ui/Button';
|
import { Button } from '../../../components/ui/Button';
|
||||||
import { Input } from '../../../components/ui/Input';
|
import { Input } from '../../../components/ui/Input';
|
||||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
|
||||||
|
|
||||||
// Validation schema
|
// Validation schema
|
||||||
const createUserSchema = yup.object({
|
const createUserSchema = yup.object({
|
||||||
|
|
@ -139,8 +138,16 @@ const UserAdminFormPage: React.FC = () => {
|
||||||
if (isEdit && userLoading) {
|
if (isEdit && userLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex justify-center items-center py-12">
|
<div className="space-y-6 animate-pulse">
|
||||||
<LoadingSpinner />
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||||
|
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Wallet, Loader2 } from 'lucide-react';
|
import { Wallet, Loader2 } from 'lucide-react';
|
||||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|
||||||
import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks';
|
import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks';
|
||||||
import { WalletStatus, WALLET_LABELS } from '../core/_models';
|
import { WalletStatus, WALLET_LABELS } from '../core/_models';
|
||||||
|
|
||||||
|
|
@ -64,8 +63,32 @@ const WalletListPage = () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<LoadingSpinner />
|
<div>
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2 animate-pulse"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-64 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
|
||||||
|
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-11 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue