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ها را در اسرع وقت پیادهسازی کنید تا بتوانیم داشبورد را کامل کنیم.** 🙏
|
||||
|
||||
**در صورت نیاز به توضیحات بیشتر یا تغییرات، لطفاً اطلاع دهید.**
|
||||
|
|
@ -93,9 +93,11 @@ const ProtectedRoute = ({ children }: { children: any }) => {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -206,9 +208,11 @@ const App = () => {
|
|||
<AuthProvider>
|
||||
<Router>
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Layout>
|
||||
}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ export const StatsCard = ({
|
|||
const isNegative = change && change < 0;
|
||||
|
||||
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-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" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
|||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<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 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center space-x-4 space-x-reverse">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const Layout = () => {
|
|||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
Settings,
|
||||
|
|
@ -185,14 +185,58 @@ interface SidebarProps {
|
|||
|
||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
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) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(title)
|
||||
setExpandedItems(prev => {
|
||||
const newItems = prev.includes(title)
|
||||
? prev.filter(item => item !== title)
|
||||
: [...prev, title]
|
||||
);
|
||||
: [...prev, title];
|
||||
return newItems;
|
||||
});
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem, depth = 0) => {
|
||||
|
|
@ -205,8 +249,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
<div key={item.title} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleExpanded(item.title)}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors
|
||||
text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700`}
|
||||
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 hover:shadow-sm`}
|
||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||
>
|
||||
<item.icon className="ml-3 h-5 w-5" />
|
||||
|
|
@ -237,9 +281,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
}
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${isActive
|
||||
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white'
|
||||
`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 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 hover:shadow-sm'
|
||||
}`
|
||||
}
|
||||
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
|
||||
lg:translate-x-0 lg:block
|
||||
${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 */}
|
||||
<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>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* 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))}
|
||||
</nav>
|
||||
|
||||
{/* 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="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const Button = ({
|
|||
className = '',
|
||||
...rest
|
||||
}: 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 = {
|
||||
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ export const Modal = ({
|
|||
|
||||
<div className={`
|
||||
relative w-full ${sizeClasses[size]}
|
||||
bg-white dark:bg-gray-800 rounded-lg shadow-xl
|
||||
transform transition-all
|
||||
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
|
||||
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">
|
||||
<SectionSubtitle>{title}</SectionSubtitle>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface LabelProps extends TypographyProps {
|
|||
|
||||
// Page Headers
|
||||
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}
|
||||
</h1>
|
||||
);
|
||||
|
|
@ -109,7 +109,7 @@ export const FormHeader = ({ title, subtitle, backButton, actions, className = '
|
|||
|
||||
// Page Container with consistent mobile spacing
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ export const ThemeProvider = ({ children }: { children: any }) => {
|
|||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
const initialTheme = savedTheme || 'light';
|
||||
setMode(initialTheme);
|
||||
|
||||
if (initialTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
|
||||
@layer components {
|
||||
.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 {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Modal } from '../components/ui/Modal';
|
|||
import { Pagination } from '../components/ui/Pagination';
|
||||
import { UserForm } from '../components/forms/UserForm';
|
||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||
import { LoadingSpinner } from '../components/ui/LoadingSpinner';
|
||||
import { TableColumn } from '../types';
|
||||
import { UserFormData } from '../utils/validationSchemas';
|
||||
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
|
||||
|
|
@ -211,7 +210,9 @@ const Users = () => {
|
|||
</div>
|
||||
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { useAdminUser } from '../core/_hooks';
|
||||
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
||||
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
||||
|
|
@ -12,7 +11,47 @@ const AdminUserDetailPage = () => {
|
|||
|
||||
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 (!user) return <div>کاربر یافت نشد</div>;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { usePermissions } from '../../permissions/core/_hooks';
|
|||
import { useRoles } from '../../roles/core/_hooks';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
|
@ -134,9 +133,19 @@ const AdminUserFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingUser) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
<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-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 { Button } from '../../../components/ui/Button';
|
||||
import { Input } from '../../../components/ui/Input';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { FileUploader } from '../../../components/ui/FileUploader';
|
||||
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
|
|
@ -116,9 +115,19 @@ const CategoryFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingCategory) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
<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(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 { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
||||
import { SingleSelectAutocomplete } from "@/components/ui/SingleSelectAutocomplete";
|
||||
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;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { FileUploader } from "@/components/ui/FileUploader";
|
|||
import { Button } from "@/components/ui/Button";
|
||||
import { useLandingHero, useUpdateLandingHero } from "./core/_hooks";
|
||||
import { LandingHeroData, HeroImage } from "./core/_models";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { PlusCircle, Trash2, Save } from "lucide-react";
|
||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer } from "@/components/ui/Typography";
|
||||
|
||||
const heroImageSchema = yup.object({
|
||||
alt_text: yup.string().required("متن ALT الزامی است"),
|
||||
|
|
@ -92,9 +92,19 @@ export const HeroSliderPage = () => {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
<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(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 { useShippingMethods } from '@/pages/shipping-methods/core/_hooks';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
||||
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) {
|
||||
return (
|
||||
<PageContainer>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { CreditCard } from 'lucide-react';
|
|||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks';
|
||||
import { persianToEnglish } from '@/utils/numberUtils';
|
||||
|
||||
|
|
@ -141,9 +140,19 @@ const CardFormPage = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
<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(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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { CreditCard, Loader2 } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
|
||||
import { IPGStatus, IPG_LABELS } from '../core/_models';
|
||||
|
||||
|
|
@ -64,8 +63,32 @@ const IPGListPage = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
<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-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>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { usePermission, useCreatePermission, useUpdatePermission } from '../core
|
|||
import { PermissionFormData } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
||||
|
|
@ -81,9 +80,19 @@ const PermissionFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingPermission) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
<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(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 { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||
import { ArrowRight, Plus, Trash2 } from "lucide-react";
|
||||
|
|
@ -142,9 +141,19 @@ const ProductOptionFormPage = () => {
|
|||
|
||||
if (isLoadingOption) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner />
|
||||
<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(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 { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign, Hash, Layers, Settings } from 'lucide-react';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { useProduct } from '../core/_hooks';
|
||||
import { PRODUCT_TYPE_LABELS } from '../core/_models';
|
||||
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);
|
||||
|
||||
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 (!product) return <div>محصول یافت نشد</div>;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { ProductFormData, ProductImage, ProductVariantFormData, PRODUCT_TYPES, P
|
|||
import { MultiSelectAutocomplete } from "@/components/ui/MultiSelectAutocomplete";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { VariantManager } from "@/components/ui/VariantManager";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
|
|
@ -420,9 +419,19 @@ const ProductFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingProduct) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
<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(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 { Product } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
import { PageContainer } from "@/components/ui/Typography";
|
||||
import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { persianToEnglish } from '../../../utils/numberUtils';
|
||||
|
|
@ -152,16 +152,17 @@ const ProductsListPage = () => {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری محصولات</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
|
|
@ -184,6 +185,23 @@ const ProductsListPage = () => {
|
|||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
||||
{i === 3 ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex-1"></div>
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex-1"></div>
|
||||
</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-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
|
|
@ -194,7 +212,7 @@ const ProductsListPage = () => {
|
|||
placeholder="جستجو در نام محصول..."
|
||||
value={filters.search}
|
||||
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"
|
||||
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>
|
||||
|
|
@ -243,7 +261,7 @@ const ProductsListPage = () => {
|
|||
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"
|
||||
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"
|
||||
|
|
@ -254,11 +272,12 @@ const ProductsListPage = () => {
|
|||
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"
|
||||
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>
|
||||
|
||||
{/* Products Table */}
|
||||
|
|
@ -481,6 +500,7 @@ const ProductsListPage = () => {
|
|||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText } from 'lucide-react';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { useRole } from '../core/_hooks';
|
||||
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
||||
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
||||
|
|
@ -12,7 +11,50 @@ const RoleDetailPage = () => {
|
|||
|
||||
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 (!role) return <div>نقش یافت نشد</div>;
|
||||
|
||||
|
|
@ -170,7 +212,7 @@ const RoleDetailPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { useRole, useCreateRole, useUpdateRole } from '../core/_hooks';
|
|||
import { RoleFormData } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { ArrowRight, Shield } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
||||
|
|
@ -70,7 +69,21 @@ const RoleFormPage = () => {
|
|||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { useState } from "react";
|
|||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useRole, useRolePermissions, usePermissions, useAssignPermission, useRemovePermission } from "../core/_hooks";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Permission } from "@/types/auth";
|
||||
import { ArrowRight, Plus, Trash2, Check, Shield } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer } from "@/components/ui/Typography";
|
||||
|
||||
const RolePermissionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -56,11 +56,33 @@ const RolePermissionsPage = () => {
|
|||
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -93,8 +115,10 @@ const RolePermissionsPage = () => {
|
|||
|
||||
<div className="p-6">
|
||||
{permissionsLoading ? (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner />
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -143,8 +167,10 @@ const RolePermissionsPage = () => {
|
|||
|
||||
<div className="p-6">
|
||||
{allPermissionsLoading ? (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner />
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -214,7 +240,7 @@ const RolePermissionsPage = () => {
|
|||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useCreateShippingMethod, useShippingMethod, useUpdateShippingMethod } f
|
|||
import { ShippingOpenHour } from '../core/_models';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
|
||||
|
|
@ -107,9 +106,19 @@ const ShippingMethodFormPage = () => {
|
|||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="min-h-[200px] flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
<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(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 { Button } from '@/components/ui/Button';
|
||||
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 { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
|
||||
import { ShippingMethod } from '../core/_models';
|
||||
|
|
@ -28,24 +28,59 @@ const ShippingMethodsListPage = () => {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 flex justify-center">
|
||||
<LoadingSpinner />
|
||||
<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>
|
||||
<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) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری روشهای ارسال</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
|
|
@ -143,6 +178,7 @@ const ShippingMethodsListPage = () => {
|
|||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
import { TicketStatus } from "../core/_models";
|
||||
import { PageContainer, PageTitle, SectionTitle, Label } from "@/components/ui/Typography";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
|
|
@ -155,9 +154,19 @@ const TicketDetailPage = () => {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner />
|
||||
<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(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,7 +132,9 @@ const TicketsListPage = () => {
|
|||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
|
|
@ -154,15 +156,63 @@ const TicketsListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="card p-4 sm:p-6">
|
||||
{isLoading ? (
|
||||
<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">
|
||||
<Search className="absolute right-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<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-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"
|
||||
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
|
||||
|
|
@ -178,9 +228,9 @@ const TicketsListPage = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<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-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وضعیت
|
||||
</label>
|
||||
<select
|
||||
|
|
@ -202,7 +252,7 @@ const TicketsListPage = () => {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
دپارتمان
|
||||
</label>
|
||||
<select
|
||||
|
|
@ -223,25 +273,13 @@ const TicketsListPage = () => {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
search: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<Table columns={columns} data={[]} loading />
|
||||
) : !data?.tickets || data.tickets.length === 0 ? (
|
||||
|
|
@ -251,6 +289,7 @@ const TicketsListPage = () => {
|
|||
) : (
|
||||
<>
|
||||
<Table columns={columns} data={data.tickets as any[]} />
|
||||
{data.total > 0 && (
|
||||
<Pagination
|
||||
currentPage={filters.page || 1}
|
||||
totalPages={Math.max(
|
||||
|
|
@ -261,8 +300,10 @@ const TicketsListPage = () => {
|
|||
itemsPerPage={filters.limit || 20}
|
||||
totalItems={data.total || 0}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { englishToPersian } from '../../../utils/numberUtils';
|
|||
import { PageContainer } from '../../../components/ui/Typography';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Modal } from '../../../components/ui/Modal';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
|
||||
const UserAdminDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
|
@ -56,8 +55,40 @@ const UserAdminDetailPage: React.FC = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<LoadingSpinner />
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { CreateUserRequest, UpdateUserRequest } from '../core/_models';
|
|||
import { PageContainer } from '../../../components/ui/Typography';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Input } from '../../../components/ui/Input';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
|
||||
// Validation schema
|
||||
const createUserSchema = yup.object({
|
||||
|
|
@ -139,8 +138,16 @@ const UserAdminFormPage: React.FC = () => {
|
|||
if (isEdit && userLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<LoadingSpinner />
|
||||
<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-32 mb-2"></div>
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Wallet, Loader2 } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks';
|
||||
import { WalletStatus, WALLET_LABELS } from '../core/_models';
|
||||
|
||||
|
|
@ -64,8 +63,32 @@ const WalletListPage = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
<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-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>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue