Compare commits
No commits in common. "187aa7c962322a605e4918144c654caef9ebf57e" and "ce30ade80880d98da8809563fd01d59dd328178e" have entirely different histories.
187aa7c962
...
ce30ade808
@ -76,23 +76,6 @@ export class AdminController {
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('users/:id/wallet-adjust')
|
||||
@ApiOperation({ summary: 'شارژ یا کسر مستقیم کیف پول کاربر توسط ادمین' })
|
||||
async adjustWallet(
|
||||
@Param('id') id: string,
|
||||
@Body('amount') amount: number,
|
||||
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
|
||||
@Body('description') description?: string,
|
||||
) {
|
||||
const result = await this.adminService.adjustUserWallet(id, Number(amount), type, description);
|
||||
return {
|
||||
success: true,
|
||||
message: 'موجود کیف پول با موفقیت بروزرسانی شد',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('products')
|
||||
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
|
||||
@ -254,21 +237,6 @@ export class AdminController {
|
||||
return { success: true, data: settings };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('pets')
|
||||
@ApiOperation({ summary: 'لیست پتها (مدیریت)' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
async getPets(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
const data = await this.adminService.getPets({ page, limit, search });
|
||||
return { success: true, ...data };
|
||||
}
|
||||
|
||||
// --- Doctors / Vets ---
|
||||
@Get('doctors')
|
||||
@ApiOperation({ summary: 'لیست پزشکان و متخصصان' })
|
||||
|
||||
@ -85,13 +85,6 @@ export class AdminService {
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
addresses: true,
|
||||
walletTransactions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
@ -102,38 +95,6 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async adjustUserWallet(userId: string, amount: number, type: 'deposit' | 'withdrawal' | 'refund', description?: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('کاربر مورد نظر یافت نشد');
|
||||
}
|
||||
|
||||
const adjustAmount = type === 'withdrawal' ? -Math.abs(amount) : Math.abs(amount);
|
||||
const currentBalance = Number(user.walletBalance || 0);
|
||||
|
||||
if (adjustAmount < 0 && currentBalance + adjustAmount < 0) {
|
||||
throw new HttpException('موجودی کیف پول کاربر برای کسر این مبلغ کافی نیست', 400);
|
||||
}
|
||||
|
||||
return this.prisma.$transaction([
|
||||
this.prisma.walletTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: Math.abs(amount),
|
||||
type: type === 'withdrawal' ? 'withdrawal' : 'deposit',
|
||||
status: 'completed',
|
||||
description: description || (type === 'refund' ? 'بازگشت وجه توسط ادمین' : type === 'deposit' ? 'شارژ توسط ادمین' : 'کسر توسط ادمین'),
|
||||
},
|
||||
}),
|
||||
this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
walletBalance: { increment: adjustAmount },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async updateUserRole(id: string, role: string) {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
@ -475,48 +436,6 @@ export class AdminService {
|
||||
return this.getSettings();
|
||||
}
|
||||
|
||||
async getPets(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' } },
|
||||
{ breed: { contains: query.search, mode: 'insensitive' } },
|
||||
{ user: { firstName: { contains: query.search, mode: 'insensitive' } } },
|
||||
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.pet.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true, email: true },
|
||||
},
|
||||
medicalConditions: true,
|
||||
reminders: true,
|
||||
healthLogs: {
|
||||
orderBy: { loggedDate: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.pet.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
|
||||
};
|
||||
}
|
||||
|
||||
async getDoctors() {
|
||||
return this.prisma.doctor.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
|
||||
@ -20,6 +20,7 @@ export class AuthService {
|
||||
const { phoneNumber } = sendOtpDto;
|
||||
|
||||
const code = Math.floor(10000 + Math.random() * 90000).toString();
|
||||
console.log(`[SMS OTP] Code for ${phoneNumber}: ${code}`);
|
||||
|
||||
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
|
||||
|
||||
@ -168,30 +169,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async adminLogin(body: any) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
// First check database for user with role 'Admin' or 'SUPER_ADMIN'
|
||||
const dbAdmin = await this.prisma.user.findFirst({
|
||||
where: { email: body.email, role: { in: ['Admin', 'SUPER_ADMIN', 'ADMIN'] } },
|
||||
});
|
||||
|
||||
if (dbAdmin && dbAdmin.password) {
|
||||
const isMatch = await bcrypt.compare(body.password, dbAdmin.password);
|
||||
if (isMatch) {
|
||||
const payload = { sub: dbAdmin.id, email: dbAdmin.email, role: dbAdmin.role };
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: dbAdmin,
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to validated environment variable credentials
|
||||
if (body.email === adminEmail && body.password === adminPassword) {
|
||||
if (
|
||||
body.email === 'admin@canino-iran.com' &&
|
||||
body.password === 'admin123'
|
||||
) {
|
||||
const payload = {
|
||||
sub: '12345678-1234-1234-1234-123456789012',
|
||||
email: body.email,
|
||||
@ -209,7 +190,6 @@ export class AuthService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new BadRequestException({
|
||||
message: 'ایمیل یا رمز عبور اشتباه است',
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
|
||||
@ -135,7 +135,7 @@ export class OrdersService {
|
||||
|
||||
const trackingNumber = this.generateTrackingNumber();
|
||||
|
||||
// Deduct user wallet balance and create order atomically
|
||||
// Deduct user wallet balance if payment method is wallet
|
||||
if (createOrderDto.paymentMethod === 'wallet' && userId) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
@ -147,52 +147,20 @@ export class OrdersService {
|
||||
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
walletBalance: { decrement: finalAmount },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: finalAmount,
|
||||
type: 'withdrawal',
|
||||
status: 'completed',
|
||||
description: `پرداخت سفارش ${trackingNumber}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (charityAmount > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { charityDonationTotal: { increment: charityAmount } },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.order.create({
|
||||
data: {
|
||||
userId,
|
||||
couponId,
|
||||
totalAmount: finalAmount,
|
||||
charityDonation: charityAmount,
|
||||
isRefill: Boolean(createOrderDto.isRefill),
|
||||
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
|
||||
trackingNumber,
|
||||
status: 'processing',
|
||||
orderItems: {
|
||||
create: orderItems,
|
||||
},
|
||||
} as any,
|
||||
include: {
|
||||
orderItems: {
|
||||
include: { product: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
walletBalance: { decrement: finalAmount },
|
||||
},
|
||||
});
|
||||
await this.prisma.walletTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: finalAmount,
|
||||
type: 'withdrawal',
|
||||
status: 'completed',
|
||||
description: `پرداخت سفارش ${trackingNumber}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -315,31 +283,4 @@ export class OrdersService {
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async checkAndSendRefillReminders() {
|
||||
const fiftyEightDaysAgo = new Date(Date.now() - 58 * 24 * 60 * 60 * 1000);
|
||||
const fiftyNineDaysAgo = new Date(Date.now() - 59 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const dueRefillOrders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
isRefill: true,
|
||||
createdAt: {
|
||||
gte: fiftyNineDaysAgo,
|
||||
lte: fiftyEightDaysAgo,
|
||||
},
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
for (const order of dueRefillOrders) {
|
||||
if (order.user?.mobile) {
|
||||
await this.smsService
|
||||
.sendSms(
|
||||
order.user.mobile,
|
||||
`کاربر گرامی، مکمل درمانی پت شما در حال اتمام است. جهت شارژ مجدد و استفاده از ۵٪ تخفیف دوره جدید به لینک زیر مراجعه کنید:\nhttps://canina-iran.com/shop`,
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,66 +31,6 @@ export class PetsService {
|
||||
});
|
||||
}
|
||||
|
||||
async findAllAdmin(filters: PaginationDto) {
|
||||
const {
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
} = filters;
|
||||
|
||||
const whereClause: any = {};
|
||||
if (search) {
|
||||
whereClause.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ breed: { contains: search, mode: 'insensitive' } },
|
||||
{ user: { firstName: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { lastName: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { mobile: { contains: search } } },
|
||||
];
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.pet.findMany({
|
||||
where: whereClause,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
medicalConditions: true,
|
||||
reminders: true,
|
||||
healthLogs: {
|
||||
orderBy: { loggedDate: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.pet.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string, filters: PaginationDto) {
|
||||
const {
|
||||
search,
|
||||
@ -116,7 +56,6 @@ export class PetsService {
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
include: { medicalConditions: true, reminders: true, healthLogs: true },
|
||||
}),
|
||||
this.prisma.pet.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
@ -25,24 +25,4 @@ export class GetProductsDto extends PaginationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requiresRx?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'حداقل قیمت (تومان)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
minPrice?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'حداکثر قیمت (تومان)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
maxPrice?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'فیلد مرتبسازی', enum: ['priceValue', 'createdAt', 'nameFa'] })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'جهت مرتبسازی', enum: ['asc', 'desc'] })
|
||||
@IsOptional()
|
||||
@IsEnum(['asc', 'desc'])
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
@ -13,67 +13,65 @@ export class ProductsService {
|
||||
search,
|
||||
symptom,
|
||||
requiresRx,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
} = filters;
|
||||
|
||||
const andConditions: any[] = [];
|
||||
const whereClause: any = {};
|
||||
|
||||
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
|
||||
andConditions.push({ requiresRx: requiresRx === 'true' || requiresRx === '1' });
|
||||
whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
|
||||
}
|
||||
|
||||
if (category) {
|
||||
andConditions.push({ categorySlug: category });
|
||||
whereClause.categorySlug = category;
|
||||
}
|
||||
|
||||
if (petType && petType !== 'all') {
|
||||
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } });
|
||||
whereClause.suitableFor = { in: [petType, 'هر دو'] };
|
||||
}
|
||||
|
||||
// Filter by a specific symptom (from URL param ?symptom=...)
|
||||
if (symptom) {
|
||||
andConditions.push({
|
||||
symptoms: {
|
||||
some: {
|
||||
symptom: { contains: symptom, mode: 'insensitive' },
|
||||
},
|
||||
whereClause.symptoms = {
|
||||
some: {
|
||||
symptom: { contains: symptom, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (minPrice) {
|
||||
andConditions.push({ priceValue: { gte: Number(minPrice) } });
|
||||
}
|
||||
|
||||
if (maxPrice) {
|
||||
andConditions.push({ priceValue: { lte: Number(maxPrice) } });
|
||||
};
|
||||
}
|
||||
|
||||
if (search) {
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{ artNo: { contains: search, mode: 'insensitive' } },
|
||||
{ barcode: { contains: search, mode: 'insensitive' } },
|
||||
{ nameFa: { contains: search, mode: 'insensitive' } },
|
||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
{ shortDescription: { contains: search, mode: 'insensitive' } },
|
||||
{
|
||||
symptoms: {
|
||||
some: {
|
||||
symptom: { contains: search, mode: 'insensitive' },
|
||||
},
|
||||
// If symptom filter is already applied, extend via AND to also search names/desc
|
||||
// If not, use OR across names, description AND symptoms
|
||||
const searchConditions = [
|
||||
{ artNo: { contains: search, mode: 'insensitive' } },
|
||||
{ barcode: { contains: search, mode: 'insensitive' } },
|
||||
{ nameFa: { contains: search, mode: 'insensitive' } },
|
||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
{ shortDescription: { contains: search, mode: 'insensitive' } },
|
||||
{
|
||||
symptoms: {
|
||||
some: {
|
||||
symptom: { contains: search, mode: 'insensitive' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const whereClause: any = andConditions.length > 0 ? { AND: andConditions } : {};
|
||||
if (symptom) {
|
||||
// Already have a symptom filter; combine with AND
|
||||
whereClause.AND = [
|
||||
{ symptoms: whereClause.symptoms },
|
||||
{ OR: searchConditions.filter((c) => !('symptoms' in c)) },
|
||||
];
|
||||
delete whereClause.symptoms;
|
||||
} else {
|
||||
whereClause.OR = searchConditions;
|
||||
}
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
|
||||
@ -194,28 +194,3 @@
|
||||
- [x] **TASK-25.12 [Flexible Catalog-Only Mode Settings]:** توسعه تنظیمات چندحالته «حالت کاتالوگ» (نمایش/عدم نمایش قیمت، فعال/غیرفعالسازی سبد خرید و فاکتور، تبدیل به پیشخرید).
|
||||
- [x] **TASK-25.13 [Product Pre-order System]:** ایجاد امکان پیشخرید کالا در پنل ادمین (رایگان یا با بیعانه) و ثبت نهایی بعد از موجود شدن.
|
||||
- [x] **TASK-25.14 [Stock Alert Queue & SMS Notification]:** ایجاد قابلیت «موجود شد اطلاع بده» برای کالاهای ناموجود، صف خودکار اطلاعرسانی و ارسال پیامک به محض شارژ موجودی.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۶: اصلاحات معماری، امنیتی و بهینهسازی سیستم (Batch Tasks 26.1 - 26.20)
|
||||
- [ ] **TASK-26.1 [NestJS Config Module Validation]:** افزودن `@nestjs/config` به بکاند و اعتبارسنجی متغیرهای محیطی در زمان Boot سرور.
|
||||
- [ ] **TASK-26.2 [Unit & E2E Financial Test Coverage]:** تکمیل پوشش تستهای مالی کیفپول و ثبت سفارشات در بکاند.
|
||||
- [ ] **TASK-26.3 [Prisma Enums Refactoring]:** جایگزینی رشتههای VarChar با Prisma Enums برای مناسب پت، وضعیت سفارش و نقشها.
|
||||
- [x] **TASK-26.4 [Remove Hardcoded Admin Credentials]:** حذف اطلاعات هاردکدشده ادمین از `auth.service.ts` و انتقال به دیتابیس با پسورد هششده.
|
||||
- [x] **TASK-26.5 [Remove OTP Console Log]:** حذف لاگ شدن کد OTP در کنسول سرور جهت جلوگیری از افشای امنیتی.
|
||||
- [ ] **TASK-26.6 [JWT Refresh Token Implementation]:** پیادهسازی مکانیزم کامل Refresh Token برای تمدید خودکار نشست کاربر.
|
||||
- [ ] **TASK-26.7 [SSR & HttpOnly Cookie Auth]:** ذخیره توکنها درون HttpOnly Cookie جهت همگامسازی کامل Server & Client Components در Next.js.
|
||||
- [x] **TASK-26.8 [Logout State Cleanup]:** پاکسازی کامل کاتالوگ، سبد خرید و تمام کشهای کاربر هنگام خروج (Logout Cleanup).
|
||||
- [x] **TASK-26.9 [Admin Token Validation Guard]:** اعتبارسنجی واقعی توکن ادمین در `ProtectedRoute.tsx` از طریق فراخوانی API پروفایل در هنگام لود پنل.
|
||||
- [x] **TASK-26.10 [Fix Pagination & Search Query Overwrite]:** اصلاح منطق `whereClause` در `products.service.ts` جهت جلوگیری از اوررایت شدن فیلترها.
|
||||
- [x] **TASK-26.11 [Price Range & Sorting Filters]:** افزودن فیلتر محدوده قیمت (`minPrice`, `maxPrice`) و مرتبسازی (`sort`) به DTO و اندپویینتهای محصولات.
|
||||
- [ ] **TASK-26.12 [Sync Fallback IDs with Database]:** همگامسازی شناسه محصولات استاتیک فرانتاند با دیتابیس واقعی جهت جلوگیری از 404 در سفارش.
|
||||
- [ ] **TASK-26.13 [Admin Products Table Pagination]:** پیادهسازی پجینیشن سرورساید کامل در جدول مدیریت محصولات پنل ادمین.
|
||||
- [x] **TASK-26.14 [Admin Wallet Adjust Endpoint]:** ایجاد اندپویینت `POST /api/users/:id/wallet-adjust` و فرم شارژ/کسر مستقیم کیفپول در پنل ادمین.
|
||||
- [x] **TASK-26.15 [Admin User Details, Addresses & Transactions Modal]:** امکان مشاهده کامل آدرسها و ریزتراکنشهای کیفپول کاربر در پنل ادمین.
|
||||
- [x] **TASK-26.16 [Atomic Prisma Transaction for Wallet Top-up]:** اتمیک کردن عملیات شارژ کیفپول در دیتابیس با `prisma.$transaction`.
|
||||
- [x] **TASK-26.17 [Admin Pet Health Records & Pagination]:** امکان مشاهده پرونده پزشکی، یادآورها و لاگهای سلامت پت در پنل ادمین همراه با پجینیشن کامل لیست پتها.
|
||||
- [ ] **TASK-26.18 [Pet Image File Upload]:** امکان آپلود مستقیم فایل تصویر پت به سرور در فرمهای فرانتاند.
|
||||
- [x] **TASK-26.19 [Atomic Order Creation Transaction & Refill Cron Job]:** اتمیک کردن ساخت سفارش با کیفپول در بکاند و ایجاد Cron Job روزانه برای پیامک خودکار یادآوری Refill.
|
||||
- [x] **TASK-26.20 [Reports Excel Export, Dashboard Live Chart & Video View Counter]:** افزودن قابلیت دانلود خروجی اکسل در صفحه گزارشات، اتصال نمودار داشبورد به دیتای واقعی و ارسال درخواست PATCH افزایش بازدید ویدیوها.
|
||||
|
||||
|
||||
@ -1,43 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setIsValid(false);
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
api.get('/users/profile')
|
||||
.then(() => {
|
||||
setIsValid(true);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('adminToken');
|
||||
setIsValid(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsValidating(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
if (!token || !isValid) {
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@ -94,30 +94,7 @@ export default function Reports() {
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">آمار فروش، رفتار کاربران و عملکرد تخفیفها</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!reportData) return;
|
||||
const rows = [
|
||||
['گزارش کلی سیستم کانینا ایران'],
|
||||
['درآمد کل (تومان)', overview?.totalRevenue || 0],
|
||||
['تعداد سفارشات', overview?.totalOrders || 0],
|
||||
['تعداد کاربران', overview?.totalUsers || 0],
|
||||
['میانگین ارزش سفارش', overview?.avgOrderValue || 0],
|
||||
[],
|
||||
['محصولات پرفروش', 'تعداد فروش'],
|
||||
...(bestSellers || []).map(b => [b.name, b.quantity]),
|
||||
];
|
||||
const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + rows.map(e => e.join(',')).join('\n');
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', encodedUri);
|
||||
link.setAttribute('download', `Canina_Sales_Report_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
className="bg-white border-2 border-purple-200 text-purple-700 hover:bg-purple-50 px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-sm"
|
||||
>
|
||||
<button className="bg-white border-2 border-purple-200 text-purple-700 hover:bg-purple-50 px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-sm">
|
||||
<Download className="w-5 h-5" />
|
||||
خروجی اکسل (Excel)
|
||||
</button>
|
||||
|
||||
@ -118,10 +118,7 @@ export default function VideosPage() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedVideo(video);
|
||||
videoService.incrementViews(video.id);
|
||||
}}
|
||||
onClick={() => setSelectedVideo(video)}
|
||||
>
|
||||
<div className="relative aspect-video rounded-[2.5rem] overflow-hidden mb-6 shadow-2xl border border-white/5">
|
||||
<SafeImage
|
||||
|
||||
@ -31,13 +31,5 @@ export const videoService = {
|
||||
console.error('Failed to fetch video details:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
incrementViews: async (id: string) => {
|
||||
try {
|
||||
await api.patch(`/videos/${id}/view`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to increment video views:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -164,9 +164,7 @@ export const useUserStore = create<UserStore>()(
|
||||
logout: () => {
|
||||
authService.logout();
|
||||
try { usePetStore.getState().reset(); } catch {}
|
||||
try { useCartStore.getState().clearCart(); } catch {}
|
||||
try { localStorage.removeItem("canina-pets"); } catch {}
|
||||
try { localStorage.removeItem("canina-cart-storage"); } catch {}
|
||||
set({
|
||||
role: "User_Guest",
|
||||
isLoggedIn: false,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user