This commit is contained in:
hosseintaromi 2026-01-08 17:36:52 +03:30
parent ef76defb28
commit bfd1ea72a5
35 changed files with 1492 additions and 546 deletions

View File

@ -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ها را در اسرع وقت پیاده‌سازی کنید تا بتوانیم داشبورد را کامل کنیم.** 🙏
**در صورت نیاز به توضیحات بیشتر یا تغییرات، لطفاً اطلاع دهید.**

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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">

View File

@ -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',

View File

@ -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>

View File

@ -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>
); );

View File

@ -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');
} }
}, []); }, []);

View File

@ -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 {

View File

@ -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">

View File

@ -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>;

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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 (

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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>
); );

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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>;

View File

@ -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>
); );
} }

View File

@ -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>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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;

View File

@ -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>
); );
}; };

View File

@ -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>
); );
} }

View File

@ -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>
); );
}; };

View File

@ -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>
); );
} }

View File

@ -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>
); );
}; };

View File

@ -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>
); );

View File

@ -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>
); );

View File

@ -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>
); );