diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 73b1aca..bc1408e 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -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) { + const settings = await this.adminService.updateSettings(data); + return { success: true, data: settings }; + } } diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index 04b3083..50b66fd 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -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 {} diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index d5e5a35..f281a10 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -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) { + // 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(); + } } diff --git a/backend/src/admin/reports.controller.ts b/backend/src/admin/reports.controller.ts new file mode 100644 index 0000000..a77188a --- /dev/null +++ b/backend/src/admin/reports.controller.ts @@ -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 }; + } +} diff --git a/backend/src/admin/reports.service.ts b/backend/src/admin/reports.service.ts new file mode 100644 index 0000000..0273574 --- /dev/null +++ b/backend/src/admin/reports.service.ts @@ -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 = {}; + + 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 + }; + } +} diff --git a/frontend/admin-panel/src/App.tsx b/frontend/admin-panel/src/App.tsx index 27f4d71..9991016 100644 --- a/frontend/admin-panel/src/App.tsx +++ b/frontend/admin-panel/src/App.tsx @@ -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() { } /> } /> } /> - کدهای تخفیف} /> - تنظیمات سیستم} /> + } /> + } /> + } /> diff --git a/frontend/admin-panel/src/components/Sidebar.tsx b/frontend/admin-panel/src/components/Sidebar.tsx index c5fcd07..dcd52cf 100644 --- a/frontend/admin-panel/src/components/Sidebar.tsx +++ b/frontend/admin-panel/src/components/Sidebar.tsx @@ -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' }, ]; diff --git a/frontend/admin-panel/src/components/ui/Pagination.tsx b/frontend/admin-panel/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..1ac842a --- /dev/null +++ b/frontend/admin-panel/src/components/ui/Pagination.tsx @@ -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 ( +
+
+ + + + صفحه {currentPage} از {totalPages} + + + +
+
+ ); +} diff --git a/frontend/admin-panel/src/pages/Coupons.tsx b/frontend/admin-panel/src/pages/Coupons.tsx new file mode 100644 index 0000000..caa6c37 --- /dev/null +++ b/frontend/admin-panel/src/pages/Coupons.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [couponToDelete, setCouponToDelete] = useState(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 ( +
+
+
+

+ + مدیریت کدهای تخفیف +

+

ساخت و مدیریت کدهای تخفیف فروشگاه

+
+ +
+
+ + +
+
+
+ +
+ {/* Add Coupon Form */} +
+
+

+ + افزودن کد جدید +

+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ + {/* Coupons List */} +
+
+
+ + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + )) + ) : ( + coupons.map((coupon) => ( + + + + + + + + )) + )} + +
کد تخفیفمبلغ تخفیفتعداد استفادهوضعیتعملیات
{coupon.code}{Number(coupon.discountValue).toLocaleString()} تومان{coupon._count?.orders || 0} بار + + +
+ +
+
+
+
+
+
+ + setIsDeleteModalOpen(false)} + /> +
+ ); +} diff --git a/frontend/admin-panel/src/pages/Orders.tsx b/frontend/admin-panel/src/pages/Orders.tsx index 514af1f..fb00e61 100644 --- a/frontend/admin-panel/src/pages/Orders.tsx +++ b/frontend/admin-panel/src/pages/Orders.tsx @@ -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 = { 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 = { export default function Orders() { const [orders, setOrders] = useState([]); 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(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() {

مشاهده فاکتورها، تغییر وضعیت و درج کد رهگیری پستی

-
- -
+
+ +
{ setSearch(e.target.value); setPage(1); }} />
@@ -145,6 +171,7 @@ export default function Orders() {
+
); diff --git a/frontend/admin-panel/src/pages/Products.tsx b/frontend/admin-panel/src/pages/Products.tsx index ae6dcd2..f4c1ffc 100644 --- a/frontend/admin-panel/src/pages/Products.tsx +++ b/frontend/admin-panel/src/pages/Products.tsx @@ -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([]); 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(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() {

مدیریت کاتالوگ، موجودی انبار و اطلاعات SEO

-
-
+
+ +
{ setSearch(e.target.value); setPage(1); }} />
- @@ -196,6 +226,7 @@ export default function Products() {
+
(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
; + } + + if (!data) return
خطا در دریافت اطلاعات
; + + const { overview, salesTimeline, bestSellers } = data; + + const customerData = [ + { name: 'مشتریان B2B', value: overview.b2bRevenue }, + { name: 'مشتریان عادی', value: overview.b2cRevenue }, + ]; + + return ( +
+
+
+

+ + گزارشات تحلیلی پیشرفته +

+

نمای جامع از عملکرد فروشگاه و تحلیل رفتار مشتریان

+
+ +
+ + {/* Overview Cards */} +
+
+
+ +
+
+

کل درآمد فروشگاه

+

{Number(overview.totalRevenue).toLocaleString()} تومان

+
+
+ +
+
+ +
+
+

درآمد از همکاران (B2B)

+

{Number(overview.b2bRevenue).toLocaleString()} تومان

+
+
+ +
+
+ +
+
+

مبالغ خیریه جمع‌آوری شده

+

{Number(overview.totalCharity).toLocaleString()} تومان

+
+
+ +
+
+ +
+
+

تعداد کل سفارشات موفق

+

{overview.totalOrders} سفارش

+
+
+
+ +
+ {/* Sales Timeline Area Chart */} +
+

روند درآمد فروشگاه

+
+ + + + + + + + + + + `${val / 1000}k`} /> + [`${value.toLocaleString()} تومان`, 'درآمد']} + labelStyle={{ color: '#374151', fontWeight: 'bold', marginBottom: '8px' }} + /> + + + +
+
+ + {/* Customer Breakdown Pie Chart */} +
+

درآمد به تفکیک نوع کاربر

+
+ + + + {customerData.map((entry, index) => ( + + ))} + + [`${value.toLocaleString()} تومان`, 'درآمد']} /> + + +
+
+ {customerData.map((item, index) => ( +
+
+ + {item.name} +
+ {Number(item.value).toLocaleString()} تومان +
+ ))} +
+
+ + {/* Top Products Bar Chart */} +
+

۵ محصول پرفروش (بر اساس تعداد فروش)

+
+ + + + + + [`${value} عدد`, 'تعداد فروش']} + cursor={{ fill: '#f3f4f6' }} + /> + + + +
+
+
+
+ ); +} diff --git a/frontend/admin-panel/src/pages/Settings.tsx b/frontend/admin-panel/src/pages/Settings.tsx new file mode 100644 index 0000000..95e4c3d --- /dev/null +++ b/frontend/admin-panel/src/pages/Settings.tsx @@ -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 ( +
+
+
+

+ + تنظیمات فروشگاه +

+

مدیریت پارامترهای اصلی و سیستمی کانی‌نا

+
+
+ + {isLoading ? ( +
+ ) : ( +
+ + {/* Financial Settings */} +
+

+ + تنظیمات مالی و ارسال +

+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +

سفارشات زیر این مبلغ امکان ثبت نخواهند داشت (۰ = بدون محدودیت).

+
+
+
+ + {/* B2B Settings */} +
+

+ + همکاران B2B +

+ +
+
+ + 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" + /> +

این درصد به صورت خودکار روی قیمت تمام محصولات برای کاربران تایید شده B2B اعمال می‌شود.

+
+
+
+ + {/* System Settings */} +
+

+ + وضعیت سیستم +

+ +
+
+ +
+

حالت تعمیرات (Maintenance Mode)

+

با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و پیام «در حال بروزرسانی» نمایش داده می‌شود.

+
+
+ +
+
+ +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/admin-panel/src/pages/Users.tsx b/frontend/admin-panel/src/pages/Users.tsx index 4845a19..0efc08b 100644 --- a/frontend/admin-panel/src/pages/Users.tsx +++ b/frontend/admin-panel/src/pages/Users.tsx @@ -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([]); 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(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() {

مشاهده و تایید حساب‌های کاربری و کلینیک‌ها

-
- -
+
+ +
{ setSearch(e.target.value); setPage(1); }} />
+
@@ -163,6 +190,7 @@ export default function Users() {
+
);