feat: implement advanced reports, pagination for all tables, global settings, and discounts modules

This commit is contained in:
پارسا آقایی 2026-06-12 16:28:06 +03:30
parent a7ebb8d792
commit 69502fc380
14 changed files with 1042 additions and 71 deletions

View File

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

View File

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

View File

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

View 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 };
}
}

View 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
};
}
}

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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