feat: implement advanced reports, pagination for all tables, global settings, and discounts modules
This commit is contained in:
parent
a7ebb8d792
commit
69502fc380
@ -1,4 +1,4 @@
|
||||
import { Controller, Get, UseGuards, Param, Put, Body } from '@nestjs/common';
|
||||
import { Controller, Get, UseGuards, Param, Put, Post, Body, Delete, Query } from '@nestjs/common';
|
||||
import { AdminService } from './admin.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ -18,12 +18,14 @@ export class AdminController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('users')
|
||||
async getUsers() {
|
||||
const users = await this.adminService.getUsers();
|
||||
return {
|
||||
success: true,
|
||||
data: users
|
||||
};
|
||||
async getUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('role') role?: string
|
||||
) {
|
||||
const data = await this.adminService.getUsers({ page, limit, search, role });
|
||||
return { success: true, ...data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -38,12 +40,14 @@ export class AdminController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('products')
|
||||
async getProducts() {
|
||||
const products = await this.adminService.getProducts();
|
||||
return {
|
||||
success: true,
|
||||
data: products
|
||||
};
|
||||
async getProducts(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('category') category?: string
|
||||
) {
|
||||
const data = await this.adminService.getProducts({ page, limit, search, category });
|
||||
return { success: true, ...data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -68,12 +72,14 @@ export class AdminController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('orders')
|
||||
async getOrders() {
|
||||
const orders = await this.adminService.getOrders();
|
||||
return {
|
||||
success: true,
|
||||
data: orders
|
||||
};
|
||||
async getOrders(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string
|
||||
) {
|
||||
const data = await this.adminService.getOrders({ page, limit, search, status });
|
||||
return { success: true, ...data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -85,4 +91,48 @@ export class AdminController {
|
||||
data: order
|
||||
};
|
||||
}
|
||||
|
||||
// --- Coupons ---
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('coupons')
|
||||
async getCoupons() {
|
||||
const coupons = await this.adminService.getCoupons();
|
||||
return { success: true, data: coupons };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('coupons')
|
||||
async createCoupon(@Body() data: { code: string; discountValue: number }) {
|
||||
const coupon = await this.adminService.createCoupon(data);
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('coupons/:id/toggle')
|
||||
async toggleCoupon(@Param('id') id: string, @Body('isActive') isActive: boolean) {
|
||||
const coupon = await this.adminService.toggleCoupon(id, isActive);
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('coupons/:id')
|
||||
async deleteCoupon(@Param('id') id: string) {
|
||||
await this.adminService.deleteCoupon(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('settings')
|
||||
async getSettings() {
|
||||
const settings = await this.adminService.getSettings();
|
||||
return { success: true, data: settings };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('settings')
|
||||
async updateSettings(@Body() data: Record<string, string>) {
|
||||
const settings = await this.adminService.updateSettings(data);
|
||||
return { success: true, data: settings };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService]
|
||||
controllers: [AdminController, ReportsController],
|
||||
providers: [AdminService, ReportsService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@ -30,10 +30,34 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
async getUsers(query: any) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
||||
{ lastName: { contains: query.search, mode: 'insensitive' } },
|
||||
{ email: { contains: query.search, mode: 'insensitive' } },
|
||||
{ mobile: { contains: query.search } }
|
||||
];
|
||||
}
|
||||
if (query.role) {
|
||||
if (query.role === 'B2B') {
|
||||
where.role = { contains: 'B2B' };
|
||||
} else {
|
||||
where.role = query.role;
|
||||
}
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.user.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }),
|
||||
this.prisma.user.count({ where })
|
||||
]);
|
||||
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) } };
|
||||
}
|
||||
|
||||
async updateUserRole(id: string, role: string) {
|
||||
@ -43,10 +67,28 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async getProducts() {
|
||||
return this.prisma.product.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
async getProducts(query: any) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.search, mode: 'insensitive' } },
|
||||
{ artNo: { contains: query.search } }
|
||||
];
|
||||
}
|
||||
if (query.category) {
|
||||
where.category = query.category;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.product.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }),
|
||||
this.prisma.product.count({ where })
|
||||
]);
|
||||
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) } };
|
||||
}
|
||||
|
||||
async updateProduct(id: string, data: any) {
|
||||
@ -65,14 +107,28 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async getOrders() {
|
||||
return this.prisma.order.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: true,
|
||||
orderItems: true
|
||||
}
|
||||
});
|
||||
async getOrders(query: any) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (query.search) {
|
||||
where.id = { contains: query.search };
|
||||
}
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where, skip, take: limit, orderBy: { createdAt: 'desc' },
|
||||
include: { user: true, orderItems: true }
|
||||
}),
|
||||
this.prisma.order.count({ where })
|
||||
]);
|
||||
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) } };
|
||||
}
|
||||
|
||||
async updateOrderStatus(id: string, status: string) {
|
||||
@ -81,4 +137,59 @@ export class AdminService {
|
||||
data: { status }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Coupons ---
|
||||
async getCoupons() {
|
||||
return this.prisma.coupon.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { _count: { select: { orders: true } } }
|
||||
});
|
||||
}
|
||||
|
||||
async createCoupon(data: { code: string; discountValue: number }) {
|
||||
return this.prisma.coupon.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
discountValue: data.discountValue,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async toggleCoupon(id: string, isActive: boolean) {
|
||||
return this.prisma.coupon.update({
|
||||
where: { id },
|
||||
data: { isActive }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCoupon(id: string) {
|
||||
return this.prisma.coupon.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
async getSettings() {
|
||||
const keys = ['SHIPPING_FEE', 'MIN_ORDER_AMOUNT', 'B2B_DISCOUNT_PERCENT', 'MAINTENANCE_MODE'];
|
||||
const settings = await this.prisma.uiText.findMany({
|
||||
where: { key: { in: keys } }
|
||||
});
|
||||
|
||||
// Transform to an object { SHIPPING_FEE: '50000', ... }
|
||||
return settings.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
|
||||
}
|
||||
|
||||
async updateSettings(data: Record<string, string>) {
|
||||
// Upsert all keys
|
||||
const operations = Object.entries(data).map(([key, value]) => {
|
||||
return this.prisma.uiText.upsert({
|
||||
where: { key },
|
||||
update: { value: String(value) },
|
||||
create: { key, value: String(value) }
|
||||
});
|
||||
});
|
||||
|
||||
await this.prisma.$transaction(operations);
|
||||
return this.getSettings();
|
||||
}
|
||||
}
|
||||
|
||||
15
backend/src/admin/reports.controller.ts
Normal file
15
backend/src/admin/reports.controller.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@Controller('admin/reports')
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
async getReports() {
|
||||
const data = await this.reportsService.getDashboardReports();
|
||||
return { success: true, data };
|
||||
}
|
||||
}
|
||||
80
backend/src/admin/reports.service.ts
Normal file
80
backend/src/admin/reports.service.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getDashboardReports() {
|
||||
// 1. Total revenue and charity
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { status: { not: 'cancelled' } },
|
||||
select: { totalAmount: true, charityDonation: true, createdAt: true, user: { select: { role: true } } }
|
||||
});
|
||||
|
||||
let totalRevenue = 0;
|
||||
let totalCharity = 0;
|
||||
let b2bRevenue = 0;
|
||||
let b2cRevenue = 0;
|
||||
|
||||
const salesByDate: Record<string, number> = {};
|
||||
|
||||
for (const order of orders) {
|
||||
const amount = Number(order.totalAmount);
|
||||
totalRevenue += amount;
|
||||
totalCharity += Number(order.charityDonation);
|
||||
|
||||
if (order.user?.role === 'B2B') {
|
||||
b2bRevenue += amount;
|
||||
} else {
|
||||
b2cRevenue += amount;
|
||||
}
|
||||
|
||||
// Format date YYYY-MM-DD
|
||||
const dateKey = order.createdAt.toISOString().split('T')[0];
|
||||
if (!salesByDate[dateKey]) salesByDate[dateKey] = 0;
|
||||
salesByDate[dateKey] += amount;
|
||||
}
|
||||
|
||||
// Format sales timeline for charts
|
||||
const salesTimeline = Object.entries(salesByDate)
|
||||
.map(([date, amount]) => ({ date, amount }))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
// 2. Best selling products (by quantity)
|
||||
const orderItems = await this.prisma.orderItem.groupBy({
|
||||
by: ['productId'],
|
||||
_sum: { quantity: true },
|
||||
where: { order: { status: { not: 'cancelled' } }, productId: { not: null } },
|
||||
orderBy: { _sum: { quantity: 'desc' } },
|
||||
take: 5
|
||||
});
|
||||
|
||||
// Fetch product names for top 5
|
||||
const topProductsIds = orderItems.map(item => item.productId).filter(Boolean) as string[];
|
||||
const productsInfo = await this.prisma.product.findMany({
|
||||
where: { id: { in: topProductsIds } },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
|
||||
const bestSellers = orderItems.map(item => {
|
||||
const p = productsInfo.find(prod => prod.id === item.productId);
|
||||
return {
|
||||
name: p ? p.name : 'Unknown Product',
|
||||
quantity: item._sum.quantity || 0
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
overview: {
|
||||
totalRevenue,
|
||||
totalCharity,
|
||||
totalOrders: orders.length,
|
||||
b2bRevenue,
|
||||
b2cRevenue,
|
||||
},
|
||||
salesTimeline,
|
||||
bestSellers
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,9 @@ import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Users from './pages/Users';
|
||||
import Products from './pages/Products';
|
||||
import Orders from './pages/Orders';
|
||||
import Coupons from './pages/Coupons';
|
||||
import Settings from './pages/Settings';
|
||||
import Reports from './pages/Reports';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -18,8 +21,9 @@ function App() {
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="coupons" element={<div className="p-4">کدهای تخفیف</div>} />
|
||||
<Route path="settings" element={<div className="p-4">تنظیمات سیستم</div>} />
|
||||
<Route path="coupons" element={<Coupons />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="reports" element={<Reports />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Home, Users, ShoppingBag, ShoppingCart, Tag, Settings, LogOut } from 'lucide-react';
|
||||
import { Home, Users, ShoppingBag, ShoppingCart, Tag, Settings, LogOut, TrendingUp } from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
{ icon: Home, label: 'داشبورد', path: '/' },
|
||||
{ icon: Users, label: 'کاربران', path: '/users' },
|
||||
{ icon: ShoppingBag, label: 'محصولات', path: '/products' },
|
||||
{ icon: ShoppingCart, label: 'سفارشات', path: '/orders' },
|
||||
{ icon: Tag, label: 'تخفیفها', path: '/coupons' },
|
||||
{ icon: Tag, label: 'کدهای تخفیف', path: '/coupons' },
|
||||
{ icon: TrendingUp, label: 'گزارشات', path: '/reports' },
|
||||
{ icon: Settings, label: 'تنظیمات', path: '/settings' },
|
||||
];
|
||||
|
||||
|
||||
37
frontend/admin-panel/src/components/ui/Pagination.tsx
Normal file
37
frontend/admin-panel/src/components/ui/Pagination.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-bold text-gray-700 px-4">
|
||||
صفحه {currentPage} از {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
frontend/admin-panel/src/pages/Coupons.tsx
Normal file
214
frontend/admin-panel/src/pages/Coupons.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tag, Plus, Search, Check, X, Trash2 } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import Skeleton from '../components/ui/Skeleton';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export default function Coupons() {
|
||||
const [coupons, setCoupons] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [couponToDelete, setCouponToDelete] = useState<string | null>(null);
|
||||
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newCoupon, setNewCoupon] = useState({ code: '', discountValue: 0 });
|
||||
|
||||
const fetchCoupons = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/admin/coupons');
|
||||
if (response.data?.success) {
|
||||
setCoupons(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch coupons', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoupons();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCoupon.code || newCoupon.discountValue <= 0) return;
|
||||
try {
|
||||
setIsAdding(true);
|
||||
await api.post('/admin/coupons', newCoupon);
|
||||
setNewCoupon({ code: '', discountValue: 0 });
|
||||
fetchCoupons();
|
||||
} catch (err) {
|
||||
console.error('Failed to add coupon', err);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, currentStatus: boolean) => {
|
||||
try {
|
||||
await api.put(`/admin/coupons/${id}/toggle`, { isActive: !currentStatus });
|
||||
fetchCoupons();
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle coupon', err);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (couponToDelete) {
|
||||
await api.delete(`/admin/coupons/${couponToDelete}`);
|
||||
setIsDeleteModalOpen(false);
|
||||
setCouponToDelete(null);
|
||||
fetchCoupons();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||
<Tag className="w-6 h-6 text-purple-600" />
|
||||
مدیریت کدهای تخفیف
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">ساخت و مدیریت کدهای تخفیف فروشگاه</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی کد تخفیف..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full sm:w-64 font-medium"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Add Coupon Form */}
|
||||
<div className="col-span-1">
|
||||
<form onSubmit={handleAdd} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-purple-600" />
|
||||
افزودن کد جدید
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">کد تخفیف (انگلیسی)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
dir="ltr"
|
||||
value={newCoupon.code}
|
||||
onChange={(e) => setNewCoupon({...newCoupon, code: e.target.value})}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold text-gray-900"
|
||||
placeholder="e.g. YALDA1403"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">مبلغ تخفیف (تومان)</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={newCoupon.discountValue || ''}
|
||||
onChange={(e) => setNewCoupon({...newCoupon, discountValue: Number(e.target.value)})}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold text-gray-900"
|
||||
placeholder="50000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isAdding}
|
||||
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 rounded-xl transition-colors flex justify-center items-center gap-2"
|
||||
>
|
||||
{isAdding ? <Spinner size="sm" /> : <Check className="w-5 h-5" />}
|
||||
ثبت کد تخفیف
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Coupons List */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right font-vazir">
|
||||
<thead className="bg-gray-50 text-gray-500 text-sm border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-4 px-6 font-bold">کد تخفیف</th>
|
||||
<th className="py-4 px-6 font-bold">مبلغ تخفیف</th>
|
||||
<th className="py-4 px-6 font-bold">تعداد استفاده</th>
|
||||
<th className="py-4 px-6 font-bold">وضعیت</th>
|
||||
<th className="py-4 px-6 font-bold text-center">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
|
||||
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
|
||||
<td className="py-4 px-6"><Skeleton className="h-6 w-16" /></td>
|
||||
<td className="py-4 px-6"><Skeleton className="h-6 w-16" /></td>
|
||||
<td className="py-4 px-6"><Skeleton className="h-8 w-20 mx-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
coupons.map((coupon) => (
|
||||
<tr key={coupon.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-4 px-6 font-bold text-gray-900" dir="ltr">{coupon.code}</td>
|
||||
<td className="py-4 px-6 font-bold text-purple-600">{Number(coupon.discountValue).toLocaleString()} تومان</td>
|
||||
<td className="py-4 px-6 text-gray-500 font-bold">{coupon._count?.orders || 0} بار</td>
|
||||
<td className="py-4 px-6">
|
||||
<button
|
||||
onClick={() => handleToggle(coupon.id, coupon.isActive)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold transition-colors ${
|
||||
coupon.isActive ? 'bg-green-100 text-green-700 hover:bg-red-100 hover:text-red-700' : 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
{coupon.isActive ? 'فعال' : 'غیرفعال'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCouponToDelete(coupon.id);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
className="text-red-600 hover:bg-red-50 p-2 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="حذف کد تخفیف"
|
||||
message="آیا از حذف این کد تخفیف اطمینان دارید؟ اگر این کد در سفارشاتی استفاده شده باشد، تاریخچه آن باقی میماند."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,9 +3,11 @@ import { ShoppingCart, Eye, Truck, Check, X, Search, Filter } from 'lucide-react
|
||||
import api from '../services/api';
|
||||
import Skeleton from '../components/ui/Skeleton';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
const statusStyles: Record<string, { label: string, color: string }> = {
|
||||
pending: { label: 'در حال بررسی', color: 'bg-orange-100 text-orange-700' },
|
||||
processing: { label: 'در حال پردازش', color: 'bg-yellow-100 text-yellow-700' },
|
||||
shipped: { label: 'ارسال شده', color: 'bg-blue-100 text-blue-700' },
|
||||
delivered: { label: 'تحویل داده شده', color: 'bg-green-100 text-green-700' },
|
||||
cancelled: { label: 'لغو شده', color: 'bg-red-100 text-red-700' },
|
||||
@ -14,14 +16,26 @@ const statusStyles: Record<string, { label: string, color: string }> = {
|
||||
export default function Orders() {
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Queries
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/admin/orders');
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', page.toString());
|
||||
if (search) params.append('search', search);
|
||||
if (status) params.append('status', status);
|
||||
|
||||
const response = await api.get(`/admin/orders?${params.toString()}`);
|
||||
if (response.data?.success) {
|
||||
setOrders(response.data.data);
|
||||
setTotalPages(response.data.meta?.totalPages || 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch orders', err);
|
||||
@ -31,8 +45,12 @@ export default function Orders() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, []);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
fetchOrders();
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [page, search, status]);
|
||||
|
||||
const handleUpdateStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
@ -57,18 +75,26 @@ export default function Orders() {
|
||||
<p className="text-gray-500 font-medium mt-1">مشاهده فاکتورها، تغییر وضعیت و درج کد رهگیری پستی</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all">
|
||||
<Filter className="w-4 h-4" />
|
||||
فیلتر
|
||||
</button>
|
||||
<div className="relative">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||||
<select
|
||||
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">همه وضعیتها</option>
|
||||
<option value="processing">در حال پردازش</option>
|
||||
<option value="shipped">ارسال شده</option>
|
||||
<option value="delivered">تحویل داده شده</option>
|
||||
</select>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی شماره سفارش..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full sm:w-64 font-medium"
|
||||
placeholder="جستجو (شماره سفارش)..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
|
||||
dir="rtl"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -145,6 +171,7 @@ export default function Orders() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,11 +4,18 @@ import api from '../services/api';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
import Skeleton from '../components/ui/Skeleton';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
export default function Products() {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Queries
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Confirm Modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<string | null>(null);
|
||||
@ -21,9 +28,15 @@ export default function Products() {
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/admin/products');
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', page.toString());
|
||||
if (search) params.append('search', search);
|
||||
if (category) params.append('category', category);
|
||||
|
||||
const response = await api.get(`/admin/products?${params.toString()}`);
|
||||
if (response.data?.success) {
|
||||
setProducts(response.data.data);
|
||||
setTotalPages(response.data.meta?.totalPages || 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch products', err);
|
||||
@ -33,8 +46,12 @@ export default function Products() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, []);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
fetchProducts();
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [page, search, category]);
|
||||
|
||||
const openDeleteModal = (id: string) => {
|
||||
setProductToDelete(id);
|
||||
@ -78,16 +95,29 @@ export default function Products() {
|
||||
<p className="text-gray-500 font-medium mt-1">مدیریت کاتالوگ، موجودی انبار و اطلاعات SEO</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||||
<select
|
||||
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
|
||||
value={category}
|
||||
onChange={(e) => { setCategory(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">همه دستهبندیها</option>
|
||||
<option value="مکمل دارویی">مکمل دارویی</option>
|
||||
<option value="آرایشی و بهداشتی">آرایشی و بهداشتی</option>
|
||||
<option value="تجهیزات نگهداری">تجهیزات نگهداری</option>
|
||||
</select>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی محصول..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full sm:w-64 font-medium"
|
||||
placeholder="جستجو (نام، بارکد)..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
|
||||
dir="rtl"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<button className="bg-purple-600 text-white px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-purple-700 transition-all shadow-md shadow-purple-200">
|
||||
<button className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-xl flex items-center justify-center gap-2 font-bold transition-all w-full sm:w-auto">
|
||||
<Plus className="w-5 h-5" />
|
||||
محصول جدید
|
||||
</button>
|
||||
@ -196,6 +226,7 @@ export default function Products() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
204
frontend/admin-panel/src/pages/Reports.tsx
Normal file
204
frontend/admin-panel/src/pages/Reports.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, BarChart, Bar } from 'recharts';
|
||||
import { Download, TrendingUp, Users, HeartHandshake, PackageOpen } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
|
||||
const COLORS = ['#8b5cf6', '#3b82f6', '#ec4899', '#f59e0b', '#10b981'];
|
||||
|
||||
export default function Reports() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/reports');
|
||||
if (response.data?.success) {
|
||||
setData(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch reports', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchReports();
|
||||
}, []);
|
||||
|
||||
const downloadCSV = () => {
|
||||
if (!data?.salesTimeline) return;
|
||||
|
||||
const headers = ['Date', 'Revenue'];
|
||||
const rows = data.salesTimeline.map((item: any) => [item.date, item.amount]);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8,"
|
||||
+ headers.join(",") + "\n"
|
||||
+ rows.map((e: any) => e.join(",")).join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "canina_sales_report.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-[70vh]"><Spinner size="lg" className="text-purple-600" /></div>;
|
||||
}
|
||||
|
||||
if (!data) return <div>خطا در دریافت اطلاعات</div>;
|
||||
|
||||
const { overview, salesTimeline, bestSellers } = data;
|
||||
|
||||
const customerData = [
|
||||
{ name: 'مشتریان B2B', value: overview.b2bRevenue },
|
||||
{ name: 'مشتریان عادی', value: overview.b2cRevenue },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-6 h-6 text-purple-600" />
|
||||
گزارشات تحلیلی پیشرفته
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">نمای جامع از عملکرد فروشگاه و تحلیل رفتار مشتریان</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={downloadCSV}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-5 py-2.5 rounded-xl flex items-center justify-center gap-2 font-bold transition-all shadow-md shadow-green-200"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
دانلود خروجی CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<TrendingUp className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-500 mb-1">کل درآمد فروشگاه</p>
|
||||
<h3 className="text-2xl font-black text-gray-900">{Number(overview.totalRevenue).toLocaleString()} <span className="text-sm font-medium text-gray-500">تومان</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-100 flex items-center justify-center text-blue-600">
|
||||
<Users className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-500 mb-1">درآمد از همکاران (B2B)</p>
|
||||
<h3 className="text-2xl font-black text-gray-900">{Number(overview.b2bRevenue).toLocaleString()} <span className="text-sm font-medium text-gray-500">تومان</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-pink-100 flex items-center justify-center text-pink-600">
|
||||
<HeartHandshake className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-500 mb-1">مبالغ خیریه جمعآوری شده</p>
|
||||
<h3 className="text-2xl font-black text-gray-900">{Number(overview.totalCharity).toLocaleString()} <span className="text-sm font-medium text-gray-500">تومان</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-orange-100 flex items-center justify-center text-orange-600">
|
||||
<PackageOpen className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-500 mb-1">تعداد کل سفارشات موفق</p>
|
||||
<h3 className="text-2xl font-black text-gray-900">{overview.totalOrders} <span className="text-sm font-medium text-gray-500">سفارش</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Sales Timeline Area Chart */}
|
||||
<div className="lg:col-span-2 bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-6">روند درآمد فروشگاه</h3>
|
||||
<div className="h-80 w-full" dir="ltr">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={salesTimeline}>
|
||||
<defs>
|
||||
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||
<XAxis dataKey="date" stroke="#9ca3af" fontSize={12} tickMargin={10} />
|
||||
<YAxis stroke="#9ca3af" fontSize={12} tickFormatter={(val) => `${val / 1000}k`} />
|
||||
<RechartsTooltip
|
||||
formatter={(value: number) => [`${value.toLocaleString()} تومان`, 'درآمد']}
|
||||
labelStyle={{ color: '#374151', fontWeight: 'bold', marginBottom: '8px' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="amount" stroke="#8b5cf6" strokeWidth={3} fillOpacity={1} fill="url(#colorAmount)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Breakdown Pie Chart */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-6">درآمد به تفکیک نوع کاربر</h3>
|
||||
<div className="h-64 w-full" dir="ltr">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={customerData}
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{customerData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip formatter={(value: number) => [`${value.toLocaleString()} تومان`, 'درآمد']} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{customerData.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: COLORS[index] }}></span>
|
||||
<span className="text-sm font-bold text-gray-700">{item.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">{Number(item.value).toLocaleString()} تومان</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Products Bar Chart */}
|
||||
<div className="lg:col-span-3 bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-6">۵ محصول پرفروش (بر اساس تعداد فروش)</h3>
|
||||
<div className="h-80 w-full" dir="ltr">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={bestSellers}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||
<XAxis dataKey="name" stroke="#9ca3af" fontSize={12} tickMargin={10} />
|
||||
<YAxis stroke="#9ca3af" fontSize={12} />
|
||||
<RechartsTooltip
|
||||
formatter={(value: number) => [`${value} عدد`, 'تعداد فروش']}
|
||||
cursor={{ fill: '#f3f4f6' }}
|
||||
/>
|
||||
<Bar dataKey="quantity" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
frontend/admin-panel/src/pages/Settings.tsx
Normal file
167
frontend/admin-panel/src/pages/Settings.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings as SettingsIcon, Save, Truck, ShieldAlert, Percent, AlertCircle } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
|
||||
export default function Settings() {
|
||||
const [settings, setSettings] = useState({
|
||||
SHIPPING_FEE: '',
|
||||
MIN_ORDER_AMOUNT: '',
|
||||
B2B_DISCOUNT_PERCENT: '',
|
||||
MAINTENANCE_MODE: 'false',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/admin/settings');
|
||||
if (response.data?.success) {
|
||||
setSettings({
|
||||
SHIPPING_FEE: response.data.data.SHIPPING_FEE || '0',
|
||||
MIN_ORDER_AMOUNT: response.data.data.MIN_ORDER_AMOUNT || '0',
|
||||
B2B_DISCOUNT_PERCENT: response.data.data.B2B_DISCOUNT_PERCENT || '0',
|
||||
MAINTENANCE_MODE: response.data.data.MAINTENANCE_MODE || 'false',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settings', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.put('/admin/settings', settings);
|
||||
alert('تنظیمات با موفقیت بروزرسانی شد.');
|
||||
} catch (err) {
|
||||
console.error('Failed to update settings', err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||
<SettingsIcon className="w-6 h-6 text-purple-600" />
|
||||
تنظیمات فروشگاه
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">مدیریت پارامترهای اصلی و سیستمی کانینا</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-12"><Spinner size="lg" className="text-purple-600" /></div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Financial Settings */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-4 flex items-center gap-2">
|
||||
<Truck className="w-5 h-5 text-blue-500" />
|
||||
تنظیمات مالی و ارسال
|
||||
</h3>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">هزینه ثابت ارسال (تومان)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.SHIPPING_FEE}
|
||||
onChange={(e) => setSettings({...settings, SHIPPING_FEE: e.target.value})}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">حداقل مبلغ سفارش (تومان)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.MIN_ORDER_AMOUNT}
|
||||
onChange={(e) => setSettings({...settings, MIN_ORDER_AMOUNT: e.target.value})}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold"
|
||||
dir="ltr"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2 font-medium">سفارشات زیر این مبلغ امکان ثبت نخواهند داشت (۰ = بدون محدودیت).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* B2B Settings */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-4 flex items-center gap-2">
|
||||
<Percent className="w-5 h-5 text-green-500" />
|
||||
همکاران B2B
|
||||
</h3>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">تخفیف پیشفرض همکار (درصد)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.B2B_DISCOUNT_PERCENT}
|
||||
onChange={(e) => setSettings({...settings, B2B_DISCOUNT_PERCENT: e.target.value})}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold"
|
||||
dir="ltr"
|
||||
max="100"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2 font-medium">این درصد به صورت خودکار روی قیمت تمام محصولات برای کاربران تایید شده B2B اعمال میشود.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Settings */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6 lg:col-span-2">
|
||||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-4 flex items-center gap-2">
|
||||
<ShieldAlert className="w-5 h-5 text-orange-500" />
|
||||
وضعیت سیستم
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-orange-500" />
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">حالت تعمیرات (Maintenance Mode)</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و پیام «در حال بروزرسانی» نمایش داده میشود.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.MAINTENANCE_MODE === 'true'}
|
||||
onChange={(e) => setSettings({...settings, MAINTENANCE_MODE: e.target.checked ? 'true' : 'false'})}
|
||||
/>
|
||||
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-orange-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-8 rounded-xl transition-colors flex items-center gap-2 shadow-md shadow-purple-200"
|
||||
>
|
||||
{isSaving ? <Spinner size="sm" /> : <Save className="w-5 h-5" />}
|
||||
ذخیره تغییرات
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,10 +3,17 @@ import { Users as UsersIcon, CheckCircle, XCircle, Search, Filter, Edit3, Check,
|
||||
import api from '../services/api';
|
||||
import Skeleton from '../components/ui/Skeleton';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Queries
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [role, setRole] = useState('');
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Edit State
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@ -16,9 +23,15 @@ export default function Users() {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/admin/users');
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', page.toString());
|
||||
if (search) params.append('search', search);
|
||||
if (role) params.append('role', role);
|
||||
|
||||
const response = await api.get(`/admin/users?${params.toString()}`);
|
||||
if (response.data?.success) {
|
||||
setUsers(response.data.data);
|
||||
setTotalPages(response.data.meta?.totalPages || 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users', err);
|
||||
@ -28,8 +41,12 @@ export default function Users() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
fetchUsers();
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [page, search, role]);
|
||||
|
||||
const handleEditClick = (user: any) => {
|
||||
setEditingId(user.id);
|
||||
@ -59,20 +76,30 @@ export default function Users() {
|
||||
<p className="text-gray-500 font-medium mt-1">مشاهده و تایید حسابهای کاربری و کلینیکها</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all">
|
||||
<Filter className="w-4 h-4" />
|
||||
فیلتر
|
||||
</button>
|
||||
<div className="relative">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||||
<select
|
||||
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
|
||||
value={role}
|
||||
onChange={(e) => { setRole(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">همه نقشها</option>
|
||||
<option value="B2B">همکار (B2B)</option>
|
||||
<option value="User_PetOwner">مشتری عادی</option>
|
||||
<option value="ADMIN">ادمین</option>
|
||||
</select>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی کاربر..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full sm:w-64 font-medium"
|
||||
placeholder="جستجو کاربر (نام، ایمیل، موبایل)..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
|
||||
dir="rtl"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
@ -163,6 +190,7 @@ export default function Users() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user