diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 99b15bd..4395fa9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { prescriptions Prescription[] partnerAccount PartnerAccount? paymentTransactions PaymentTransaction[] + tickets Ticket[] @@map("users") } @@ -218,6 +219,7 @@ model Pet { reminders Reminder[] healthLogs HealthLog[] prescriptions Prescription[] + tickets Ticket[] @@map("pets") } @@ -629,3 +631,44 @@ model SmsLog { @@map("sms_logs") } +model Ticket { + id String @id @default(uuid()) @db.Uuid + ticketNumber String @unique @map("ticket_number") @db.VarChar(50) + userId String @map("user_id") @db.Uuid + petId String? @map("pet_id") @db.Uuid + subject String @db.VarChar(255) + category String @default("VET_CONSULTATION") @db.VarChar(50) // VET_CONSULTATION, ORDER_SUPPORT, PRODUCT_INQUIRY, GENERAL + priority String @default("MEDIUM") @db.VarChar(20) // LOW, MEDIUM, HIGH, URGENT + status String @default("OPEN") @db.VarChar(30) // OPEN, ANSWERED, IN_PROGRESS, CLOSED + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz() + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + pet Pet? @relation(fields: [petId], references: [id], onDelete: SetNull) + messages TicketMessage[] + + @@index([userId]) + @@index([status]) + @@index([category]) + @@index([createdAt]) + @@map("tickets") +} + +model TicketMessage { + id String @id @default(uuid()) @db.Uuid + ticketId String @map("ticket_id") @db.Uuid + senderId String? @map("sender_id") @db.Uuid + senderRole String @default("USER") @map("sender_role") @db.VarChar(30) // USER, ADMIN, VET_DOCTOR + senderName String @map("sender_name") @db.VarChar(100) + message String @db.Text + attachment String? @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + + @@index([ticketId]) + @@index([createdAt]) + @@map("ticket_messages") +} + + diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 74f87cd..fedb90a 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -477,6 +477,9 @@ export class AdminService { }, }, coupon: true, + paymentTransactions: { + orderBy: { createdAt: 'desc' }, + }, }, }), this.prisma.order.count({ where }), diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7a2ac7c..5c4e159 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -28,6 +28,7 @@ import { IngredientsModule } from './ingredients/ingredients.module'; import { PrescriptionsModule } from './prescriptions/prescriptions.module'; import { B2BModule } from './b2b/b2b.module'; import { PaymentModule } from './payment/payment.module'; +import { TicketsModule } from './tickets/tickets.module'; import { Request, Response, NextFunction } from 'express'; @@ -44,6 +45,7 @@ import { Request, Response, NextFunction } from 'express'; OrdersModule, SettingsModule, PaymentModule, + TicketsModule, ThrottlerModule.forRoot([ { ttl: 60000, diff --git a/backend/src/orders/orders.service.ts b/backend/src/orders/orders.service.ts index 53dd29f..fde0d52 100644 --- a/backend/src/orders/orders.service.ts +++ b/backend/src/orders/orders.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, + Logger, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { PaginationDto } from '../common/dto/pagination.dto'; @@ -11,6 +12,8 @@ import { Prisma } from '@prisma/client'; @Injectable() export class OrdersService { + private readonly logger = new Logger(OrdersService.name); + constructor( private prisma: PrismaService, private smsService: SmsService, @@ -186,7 +189,7 @@ export class OrdersService { ); } - return this.prisma.$transaction(async (tx) => { + const createdWalletOrder = await this.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: userId }, data: { @@ -236,6 +239,21 @@ export class OrdersService { }, }); }); + + // Send Order Confirmation SMS for wallet orders + if (user.mobile) { + this.smsService + .sendOrderConfirmation( + user.mobile, + trackingNumber, + Number(finalAmount).toLocaleString('fa-IR'), + ) + .catch((err) => { + this.logger.warn(`Failed to send wallet order SMS: ${err}`); + }); + } + + return createdWalletOrder; } const isOnline = createOrderDto.paymentMethod === 'online'; diff --git a/backend/src/payment/payment.controller.ts b/backend/src/payment/payment.controller.ts index 08072e3..b933bd7 100644 --- a/backend/src/payment/payment.controller.ts +++ b/backend/src/payment/payment.controller.ts @@ -212,6 +212,30 @@ export class PaymentController { return this.paymentService.adminLiveInquiry(id); } + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Post('admin/manual-verify/:id') + @ApiOperation({ summary: 'تایید دستی تراکنش یا فیش واریزی کارت‌به‌کارت توسط ادمین' }) + async manualVerify( + @Param('id') id: string, + @Body() body: { adminNote?: string }, + ) { + return this.paymentService.adminManualVerify(id, body.adminNote); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Post('admin/manual-reject/:id') + @ApiOperation({ summary: 'رد دستی تراکنش پرداخت توسط ادمین' }) + async manualReject( + @Param('id') id: string, + @Body() body: { reason?: string }, + ) { + return this.paymentService.adminManualReject(id, body.reason); + } + @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Admin') @ApiBearerAuth() diff --git a/backend/src/payment/payment.service.ts b/backend/src/payment/payment.service.ts index 9024b54..8d65595 100644 --- a/backend/src/payment/payment.service.ts +++ b/backend/src/payment/payment.service.ts @@ -478,16 +478,22 @@ export class PaymentService { }); // Send SMS notification if order payment - if (transaction.type === 'ORDER' && transaction.order?.user?.mobile) { - const mobile = transaction.order.user.mobile; + const customerMobile = + transaction.order?.user?.mobile || + transaction.user?.mobile || + (transaction.rawRequest as { mobile?: string })?.mobile; + + if (transaction.type === 'ORDER' && customerMobile) { const trackingNum = - transaction.order.trackingNumber || transaction.order.id; + transaction.order?.trackingNumber || + transaction.orderId || + transaction.id; const formattedAmount = Number(transaction.amount).toLocaleString( 'fa-IR', ); this.smsService - .sendOrderConfirmation(mobile, trackingNum, formattedAmount) + .sendOrderConfirmation(customerMobile, trackingNum, formattedAmount) .catch((err) => { this.logger.warn(`Could not send SMS confirmation: ${err}`); }); @@ -842,16 +848,101 @@ export class PaymentService { return { transactionId: transaction.id, trackId: transaction.trackId, - currentDbStatus: transaction.status, - gatewayResponse: inquiryRes, + status: inquiryRes.status, statusMessage: statusMsg, + result: inquiryRes.result, resultMessage: resultMsg, - inquiredAt: new Date(), + amount: inquiryRes.amount, + refNumber: inquiryRes.refNumber, + cardNumber: inquiryRes.cardNumber, + paidAt: inquiryRes.paidAt, + rawInquiryResponse: inquiryRes, }; } /** - * 9. Gateway Health Check for Admin + * 9. Admin: Manually Approve / Verify a Transaction (e.g. Card-to-Card receipt or bank transfer) + */ + async adminManualVerify(transactionId: string, adminNote?: string) { + const transaction = await this.prisma.paymentTransaction.findUnique({ + where: { id: transactionId }, + include: { order: true, user: true }, + }); + + if (!transaction) { + throw new NotFoundException('تراکنش یافت نشد'); + } + + if (transaction.status === 'VERIFIED') { + throw new BadRequestException('این تراکنش قبلاً تایید شده است'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.paymentTransaction.update({ + where: { id: transactionId }, + data: { + status: 'VERIFIED', + message: adminNote || 'تایید دستی توسط مدیر سیستم (کارت به کارت / فیش بانکی)', + paidAt: new Date(), + }, + }); + + if (transaction.type === 'WALLET_TOPUP') { + await tx.user.update({ + where: { id: transaction.userId }, + data: { + walletBalance: { increment: Number(transaction.amount) }, + }, + }); + + await tx.walletTransaction.create({ + data: { + userId: transaction.userId, + amount: transaction.amount, + type: 'deposit', + status: 'completed', + description: `افزایش موجودی دستی تایید شده توسط ادمین (${adminNote || 'کارت به کارت'})`, + }, + }); + } else if (transaction.type === 'ORDER' && transaction.orderId) { + await tx.order.update({ + where: { id: transaction.orderId }, + data: { + status: 'processing', + paymentMethod: 'card_to_card', + }, + }); + } + }); + + return { success: true, message: 'تراکنش با موفقیت به صورت دستی تایید و اعمال شد' }; + } + + /** + * 10. Admin: Manually Reject a Transaction + */ + async adminManualReject(transactionId: string, reason?: string) { + const transaction = await this.prisma.paymentTransaction.findUnique({ + where: { id: transactionId }, + }); + + if (!transaction) { + throw new NotFoundException('تراکنش یافت نشد'); + } + + await this.prisma.paymentTransaction.update({ + where: { id: transactionId }, + data: { + status: 'FAILED', + message: reason || 'رد شده توسط ادمین سیستم', + }, + }); + + return { success: true, message: 'تراکنش به عنوان ناموفق/رد شده علامت‌گذاری شد' }; + } + + /** + * 11. Gateway Health Check for Admin */ async checkGatewayHealth() { return this.zibalService.checkHealth(); diff --git a/backend/src/tickets/dto/create-ticket.dto.ts b/backend/src/tickets/dto/create-ticket.dto.ts new file mode 100644 index 0000000..a971e5f --- /dev/null +++ b/backend/src/tickets/dto/create-ticket.dto.ts @@ -0,0 +1,63 @@ +import { IsString, IsNotEmpty, IsOptional, IsEnum, IsArray } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateTicketDto { + @ApiProperty({ description: 'موضوع تیکت یا عنوان مشاوره', example: 'مشاوره مصرف مکمل کانینوکال' }) + @IsString() + @IsNotEmpty() + subject: string; + + @ApiProperty({ description: 'متن پیام یا شرح وضعیت', example: 'سگ من ۵ ماهشه و می‌خواستم بدونم...' }) + @IsString() + @IsNotEmpty() + message: string; + + @ApiPropertyOptional({ description: 'دسته‌بندی تیکت', example: 'VET_CONSULTATION', enum: ['VET_CONSULTATION', 'ORDER_SUPPORT', 'PRODUCT_INQUIRY', 'GENERAL'] }) + @IsOptional() + @IsString() + category?: string; + + @ApiPropertyOptional({ description: 'اولویت تیکت', example: 'MEDIUM', enum: ['LOW', 'MEDIUM', 'HIGH', 'URGENT'] }) + @IsOptional() + @IsString() + priority?: string; + + @ApiPropertyOptional({ description: 'شناسه حیوان خانگی مرتبط (اختیاری)', example: '9a8f4171-86bb-4236-9d6d-e4bc60255156' }) + @IsOptional() + @IsString() + petId?: string; + + @ApiPropertyOptional({ description: 'لینک تصویر آزمایش یا نسخه ضمیمه شده' }) + @IsOptional() + @IsString() + attachment?: string; +} + +export class ReplyTicketDto { + @ApiProperty({ description: 'متن پاسخ تیکت', example: 'با سلام، دوز مصرفی پیشنهادی...' }) + @IsString() + @IsNotEmpty() + message: string; + + @ApiPropertyOptional({ description: 'لینک فایل ضمیمه' }) + @IsOptional() + @IsString() + attachment?: string; + + @ApiPropertyOptional({ description: 'تغییر وضعیت تیکت (توسط ادمین)', enum: ['OPEN', 'IN_PROGRESS', 'ANSWERED', 'CLOSED'] }) + @IsOptional() + @IsString() + status?: string; +} + +export class AdminUpdateTicketDto { + @ApiPropertyOptional({ description: 'وضعیت جدید تیکت', enum: ['OPEN', 'IN_PROGRESS', 'ANSWERED', 'CLOSED'] }) + @IsOptional() + @IsString() + status?: string; + + @ApiPropertyOptional({ description: 'اولویت تیکت', enum: ['LOW', 'MEDIUM', 'HIGH', 'URGENT'] }) + @IsOptional() + @IsString() + priority?: string; +} diff --git a/backend/src/tickets/dto/ticket-query.dto.ts b/backend/src/tickets/dto/ticket-query.dto.ts new file mode 100644 index 0000000..1da6541 --- /dev/null +++ b/backend/src/tickets/dto/ticket-query.dto.ts @@ -0,0 +1,32 @@ +import { IsOptional, IsString, IsNumber } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class TicketQueryDto { + @ApiPropertyOptional({ description: 'شماره صفحه', default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + page?: number = 1; + + @ApiPropertyOptional({ description: 'تعداد در هر صفحه', default: 15 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + limit?: number = 15; + + @ApiPropertyOptional({ description: 'فیلتر بر اساس وضعیت', enum: ['OPEN', 'IN_PROGRESS', 'ANSWERED', 'CLOSED'] }) + @IsOptional() + @IsString() + status?: string; + + @ApiPropertyOptional({ description: 'فیلتر بر اساس دسته‌بندی', enum: ['VET_CONSULTATION', 'ORDER_SUPPORT', 'PRODUCT_INQUIRY', 'GENERAL'] }) + @IsOptional() + @IsString() + category?: string; + + @ApiPropertyOptional({ description: 'جستجو در موضوع، شماره تیکت یا مشخصات کاربر' }) + @IsOptional() + @IsString() + search?: string; +} diff --git a/backend/src/tickets/tickets.controller.ts b/backend/src/tickets/tickets.controller.ts new file mode 100644 index 0000000..7626d49 --- /dev/null +++ b/backend/src/tickets/tickets.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + Query, + UseGuards, + Req, +} from '@nestjs/common'; +import { TicketsService } from './tickets.service'; +import { CreateTicketDto, ReplyTicketDto, AdminUpdateTicketDto } from './dto/create-ticket.dto'; +import { TicketQueryDto } from './dto/ticket-query.dto'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiOkResponse, +} from '@nestjs/swagger'; + +@ApiTags('Tickets & Vet Consultation - تیکتینگ، پشتیبانی و مشاوره آنلاین دامپزشک') +@Controller('tickets') +export class TicketsController { + constructor(private readonly ticketsService: TicketsService) {} + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @Post() + @ApiOperation({ summary: 'ثبت تیکت جدید یا درخواست مشاوره دامپزشک توسط کاربر' }) + async createTicket( + @Req() req: { user: { id: string } }, + @Body() dto: CreateTicketDto, + ) { + return this.ticketsService.createTicket(req.user.id, dto); + } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @Get('my-tickets') + @ApiOperation({ summary: 'دریافت لیست تیکت‌های کاربر لاگین شده' }) + async getMyTickets(@Req() req: { user: { id: string } }) { + return this.ticketsService.getUserTickets(req.user.id); + } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @Get(':id') + @ApiOperation({ summary: 'دریافت رشته پیام‌ها و جزئیات تیکت' }) + async getTicketDetails( + @Req() req: { user: { id: string; role?: string } }, + @Param('id') id: string, + ) { + const isAdmin = req.user.role?.includes('Admin') || false; + return this.ticketsService.getTicketDetails(id, req.user.id, isAdmin); + } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @Post(':id/messages') + @ApiOperation({ summary: 'ارسال پاسخ جدید به تیکت توسط کاربر یا ادمین' }) + async replyTicket( + @Req() req: { user: { id: string; role?: string } }, + @Param('id') id: string, + @Body() dto: ReplyTicketDto, + ) { + const isAdmin = req.user.role?.includes('Admin') || false; + return this.ticketsService.replyToTicket(id, req.user.id, dto, isAdmin); + } + + // --- ADMIN ENDPOINTS --- + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Get('admin/all') + @ApiOperation({ summary: 'مشاهده و فیلتر تمامی تیکت‌ها در پنل ادمین' }) + async getAdminTickets(@Query() query: TicketQueryDto) { + return this.ticketsService.getAdminTickets(query); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Put('admin/:id/status') + @ApiOperation({ summary: 'تغییر وضعیت و اولویت تیکت توسط ادمین' }) + async updateTicketStatus( + @Param('id') id: string, + @Body() dto: AdminUpdateTicketDto, + ) { + return this.ticketsService.updateTicketStatus(id, dto); + } +} diff --git a/backend/src/tickets/tickets.module.ts b/backend/src/tickets/tickets.module.ts new file mode 100644 index 0000000..6c43c32 --- /dev/null +++ b/backend/src/tickets/tickets.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TicketsService } from './tickets.service'; +import { TicketsController } from './tickets.controller'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [TicketsController], + providers: [TicketsService], + exports: [TicketsService], +}) +export class TicketsModule {} diff --git a/backend/src/tickets/tickets.service.ts b/backend/src/tickets/tickets.service.ts new file mode 100644 index 0000000..d29294c --- /dev/null +++ b/backend/src/tickets/tickets.service.ts @@ -0,0 +1,276 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateTicketDto, ReplyTicketDto, AdminUpdateTicketDto } from './dto/create-ticket.dto'; +import { TicketQueryDto } from './dto/ticket-query.dto'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class TicketsService { + private readonly logger = new Logger(TicketsService.name); + + constructor(private readonly prisma: PrismaService) {} + + private generateTicketNumber(): string { + const random = Math.floor(10000 + Math.random() * 90000); + return `TK-${random}`; + } + + /** + * 1. Create a new support ticket / veterinary consultation + */ + async createTicket(userId: string, dto: CreateTicketDto) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('کاربر یافت نشد'); + } + + const ticketNumber = this.generateTicketNumber(); + const senderName = `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.mobile; + + const ticket = await this.prisma.ticket.create({ + data: { + ticketNumber, + userId, + petId: dto.petId || null, + subject: dto.subject, + category: dto.category || 'VET_CONSULTATION', + priority: dto.priority || 'MEDIUM', + status: 'OPEN', + messages: { + create: { + senderId: userId, + senderRole: user.role.includes('Admin') ? 'ADMIN' : 'USER', + senderName, + message: dto.message, + attachment: dto.attachment || null, + }, + }, + }, + include: { + pet: true, + messages: { + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + return { + success: true, + message: 'تیکت شما با موفقیت ثبت شد و به زودی توسط کارشناسان پاسخ داده می‌شود.', + data: ticket, + }; + } + + /** + * 2. Get tickets of a specific user + */ + async getUserTickets(userId: string) { + const tickets = await this.prisma.ticket.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + include: { + pet: true, + messages: { + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + return { + success: true, + data: tickets, + }; + } + + /** + * 3. Get single ticket details + */ + async getTicketDetails(ticketId: string, userId?: string, isAdmin: boolean = false) { + const ticket = await this.prisma.ticket.findUnique({ + where: { id: ticketId }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + mobile: true, + email: true, + role: true, + }, + }, + pet: true, + messages: { + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!ticket) { + throw new NotFoundException('تیکت یافت نشد'); + } + + if (!isAdmin && userId && ticket.userId !== userId) { + throw new ForbiddenException('شما دسترسی به این تیکت را ندارید'); + } + + return { + success: true, + data: ticket, + }; + } + + /** + * 4. Reply to a ticket (User or Admin) + */ + async replyToTicket( + ticketId: string, + senderId: string, + dto: ReplyTicketDto, + isAdmin: boolean = false, + ) { + const ticket = await this.prisma.ticket.findUnique({ + where: { id: ticketId }, + include: { user: true }, + }); + + if (!ticket) { + throw new NotFoundException('تیکت یافت نشد'); + } + + if (!isAdmin && ticket.userId !== senderId) { + throw new ForbiddenException('شما دسترسی به این تیکت را ندارید'); + } + + const sender = await this.prisma.user.findUnique({ where: { id: senderId } }); + const senderRole = isAdmin ? (sender?.role.includes('Doctor') ? 'VET_DOCTOR' : 'ADMIN') : 'USER'; + const senderName = isAdmin + ? `${sender?.firstName || 'پشتیبان'} ${sender?.lastName || 'کنینا'}`.trim() + : `${sender?.firstName || ''} ${sender?.lastName || ''}`.trim() || sender?.mobile || 'کاربر'; + + const newStatus = isAdmin ? (dto.status || 'ANSWERED') : 'IN_PROGRESS'; + + const [newMessage] = await this.prisma.$transaction([ + this.prisma.ticketMessage.create({ + data: { + ticketId, + senderId, + senderRole, + senderName, + message: dto.message, + attachment: dto.attachment || null, + }, + }), + this.prisma.ticket.update({ + where: { id: ticketId }, + data: { + status: newStatus, + updatedAt: new Date(), + }, + }), + ]); + + return { + success: true, + message: 'پاسخ با موفقیت ارسال شد', + data: newMessage, + }; + } + + /** + * 5. Admin: List all tickets with filtering & pagination + */ + async getAdminTickets(query: TicketQueryDto) { + const page = Math.max(1, Number(query.page) || 1); + const limit = Math.max(1, Math.min(100, Number(query.limit) || 15)); + const skip = (page - 1) * limit; + + const where: Prisma.TicketWhereInput = {}; + + if (query.status && query.status !== 'ALL') { + where.status = query.status; + } + + if (query.category && query.category !== 'ALL') { + where.category = query.category; + } + + if (query.search) { + const s = query.search.trim(); + where.OR = [ + { ticketNumber: { contains: s, mode: 'insensitive' } }, + { subject: { contains: s, mode: 'insensitive' } }, + { + user: { + OR: [ + { firstName: { contains: s, mode: 'insensitive' } }, + { lastName: { contains: s, mode: 'insensitive' } }, + { mobile: { contains: s } }, + ], + }, + }, + ]; + } + + const [tickets, total] = await Promise.all([ + this.prisma.ticket.findMany({ + where, + skip, + take: limit, + orderBy: { updatedAt: 'desc' }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + mobile: true, + }, + }, + pet: true, + messages: { + take: 1, + orderBy: { createdAt: 'desc' }, + }, + }, + }), + this.prisma.ticket.count({ where }), + ]); + + return { + success: true, + data: tickets, + meta: { + total, + page, + limit, + lastPage: Math.ceil(total / limit), + }, + }; + } + + /** + * 6. Admin: Update Ticket Status / Priority + */ + async updateTicketStatus(ticketId: string, dto: AdminUpdateTicketDto) { + const ticket = await this.prisma.ticket.update({ + where: { id: ticketId }, + data: { + ...(dto.status ? { status: dto.status } : {}), + ...(dto.priority ? { priority: dto.priority } : {}), + }, + }); + + return { + success: true, + message: 'وضعیت تیکت بروزرسانی شد', + data: ticket, + }; + } +} diff --git a/frontend/admin-panel/src/components/Sidebar.tsx b/frontend/admin-panel/src/components/Sidebar.tsx index 7776d93..7dc4a19 100644 --- a/frontend/admin-panel/src/components/Sidebar.tsx +++ b/frontend/admin-panel/src/components/Sidebar.tsx @@ -28,6 +28,7 @@ const menuGroups = [ items: [ { icon: Users, label: 'کاربران', path: '/users' }, { icon: Heart, label: 'حیوانات (Pets)', path: '/pets' }, + { icon: MessageSquare, label: 'تیکت‌ها & مشاوره دامپزشک', path: '/tickets' }, { icon: Building2, label: 'مدیریت B2B & عمده', path: '/b2b' }, { icon: PhoneCall, label: 'تماس با ما & اطلاعات', path: '/contact' }, ] diff --git a/frontend/admin-panel/src/pages/Orders.tsx b/frontend/admin-panel/src/pages/Orders.tsx index 7bffac5..fcb34ed 100644 --- a/frontend/admin-panel/src/pages/Orders.tsx +++ b/frontend/admin-panel/src/pages/Orders.tsx @@ -36,6 +36,18 @@ export interface OrderItem { }; } +export interface PaymentTx { + id: string; + trackId?: string; + refNumber?: string; + cardNumber?: string; + amount?: number; + status: string; + message?: string; + gateway?: string; + createdAt: string; +} + export interface Order { id: string; trackingNumber?: string; @@ -48,17 +60,21 @@ export interface Order { isRefill?: boolean; paymentMethod?: string; address?: string; + shippingAddress?: string; orderItems?: OrderItem[]; items?: OrderItem[]; + paymentTransactions?: PaymentTx[]; user?: { firstName?: string; lastName?: string; mobile?: string; + phone?: string; email?: string; }; } const statusStyles: Record = { + pending_payment: { label: 'در انتظار پرداخت / ناموفق', color: 'bg-rose-100 text-rose-800 border-rose-200', icon: Clock }, pending: { label: 'در حال بررسی', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: Clock }, processing: { label: 'در حال پردازش', color: 'bg-amber-100 text-amber-800 border-amber-200', icon: Clock }, shipped: { label: 'ارسال شده', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Truck }, @@ -329,7 +345,7 @@ export default function Orders() { مدیریت سفارشات -

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

+

مشاهده فاکتورها، سفارش‌های در انتظار/ناموفق، تغییر وضعیت و چاپ فاکتور رسمی

@@ -339,6 +355,7 @@ export default function Orders() { onChange={(e) => { setStatus(e.target.value); setPage(1); }} > + @@ -358,6 +375,33 @@ export default function Orders() {
+ {/* Quick Filter Pills */} +
+ {[ + { label: 'همه سفارش‌ها', value: '' }, + { label: 'در انتظار پرداخت / ناموفق', value: 'pending_payment', color: 'text-rose-700 bg-rose-50 border-rose-200' }, + { label: 'در حال پردازش', value: 'processing', color: 'text-amber-700 bg-amber-50 border-amber-200' }, + { label: 'ارسال شده', value: 'shipped', color: 'text-blue-700 bg-blue-50 border-blue-200' }, + { label: 'تحویل شده', value: 'delivered', color: 'text-green-700 bg-green-50 border-green-200' }, + { label: 'لغو شده', value: 'cancelled', color: 'text-red-700 bg-red-50 border-red-200' }, + ].map((tab) => { + const isSelected = status === tab.value; + return ( + + ); + })} +
+ {/* Orders Table */}
@@ -432,6 +476,7 @@ export default function Orders() { onChange={(e) => handleUpdateStatus(order.id, e.target.value)} className={`text-xs font-bold px-3 py-1.5 rounded-xl border outline-none cursor-pointer transition-all ${statusStyles[order.status]?.color || 'bg-gray-100 text-gray-700'}`} > + @@ -544,6 +589,7 @@ export default function Orders() { onChange={(e) => setModalStatus(e.target.value)} className="w-full bg-white border border-gray-300 text-gray-900 text-sm font-bold rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500" > + @@ -574,6 +620,48 @@ export default function Orders() {
+ {/* Payment Gateway Transactions Log */} + {selectedOrder.paymentTransactions && selectedOrder.paymentTransactions.length > 0 && ( +
+

+ + لاگ تراکنش‌های درگاه پرداخت زیبال / بانکی +

+
+ {selectedOrder.paymentTransactions.map((tx) => ( +
+
+
+ شناسه رهگیری زیبال (TrackID): {tx.trackId || 'ثبت نشده'} +
+ {tx.refNumber && ( +
+ شماره ارجاع شاپرک (RRN): {tx.refNumber} +
+ )} + {tx.message && ( +
+ پیام درگاه: {tx.message} +
+ )} +
+
+ + {tx.status === 'VERIFIED' ? 'پرداخت تایید شده' : tx.status === 'PENDING' ? 'در انتظار پرداخت' : 'پرداخت ناموفق'} + +
+
+ ))} +
+
+ )} + {/* Items Table */}

diff --git a/frontend/admin-panel/src/pages/Tickets.tsx b/frontend/admin-panel/src/pages/Tickets.tsx new file mode 100644 index 0000000..615d80f --- /dev/null +++ b/frontend/admin-panel/src/pages/Tickets.tsx @@ -0,0 +1,467 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + MessageSquare, + Search, + Filter, + Stethoscope, + FileText, + User, + Clock, + CheckCircle2, + AlertCircle, + XCircle, + Send, + RefreshCw, + ChevronLeft, + ChevronRight, + ShieldCheck, + Building2, + Calendar, +} from 'lucide-react'; +import api from '../services/api'; +import { toast } from 'react-hot-toast'; + +interface TicketMessage { + id: string; + senderId?: string; + senderRole: 'USER' | 'ADMIN' | 'VET_DOCTOR'; + senderName: string; + message: string; + attachment?: string; + createdAt: string; +} + +interface Ticket { + id: string; + ticketNumber: string; + subject: string; + category: 'VET_CONSULTATION' | 'ORDER_SUPPORT' | 'PRODUCT_INQUIRY' | 'GENERAL'; + priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + status: 'OPEN' | 'IN_PROGRESS' | 'ANSWERED' | 'CLOSED'; + createdAt: string; + updatedAt: string; + user: { + id: string; + firstName?: string; + lastName?: string; + mobile: string; + }; + pet?: { + id: string; + name: string; + type: string; + breed: string; + }; + messages: TicketMessage[]; +} + +export default function Tickets() { + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [categoryFilter, setCategoryFilter] = useState('ALL'); + const [searchQuery, setSearchQuery] = useState(''); + + // Detail / Reply Modal + const [selectedTicket, setSelectedTicket] = useState(null); + const [fetchingDetail, setFetchingDetail] = useState(false); + const [replyMessage, setReplyMessage] = useState(''); + const [replyStatus, setReplyStatus] = useState('ANSWERED'); + const [sendingReply, setSendingReply] = useState(false); + + const fetchTickets = useCallback(async () => { + try { + setLoading(true); + const params: Record = { page, limit: 15 }; + if (statusFilter !== 'ALL') params.status = statusFilter; + if (categoryFilter !== 'ALL') params.category = categoryFilter; + if (searchQuery.trim()) params.search = searchQuery.trim(); + + const res = await api.get('/tickets/admin/all', { params }); + setTickets(res.data?.data || []); + setTotal(res.data?.meta?.total || 0); + } catch { + toast.error('خطا در دریافت لیست تیکت‌ها'); + } finally { + setLoading(false); + } + }, [page, statusFilter, categoryFilter, searchQuery]); + + useEffect(() => { + fetchTickets(); + }, [fetchTickets]); + + const handleOpenTicket = async (ticketId: string) => { + try { + setFetchingDetail(true); + const res = await api.get(`/tickets/${ticketId}`); + setSelectedTicket(res.data?.data || null); + setReplyStatus(res.data?.data?.status === 'OPEN' ? 'ANSWERED' : res.data?.data?.status || 'ANSWERED'); + } catch { + toast.error('خطا در بارگذاری جزئیات تیکت'); + } finally { + setFetchingDetail(false); + } + }; + + const handleSendReply = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedTicket || !replyMessage.trim()) return; + + try { + setSendingReply(true); + await api.post(`/tickets/${selectedTicket.id}/messages`, { + message: replyMessage.trim(), + status: replyStatus, + }); + toast.success('پاسخ با موفقیت برای کاربر ارسال شد'); + setReplyMessage(''); + // Reload current ticket + handleOpenTicket(selectedTicket.id); + fetchTickets(); + } catch (err: any) { + toast.error(err.response?.data?.message || 'خطا در ارسال پاسخ'); + } finally { + setSendingReply(false); + } + }; + + const handleUpdateStatus = async (status: string) => { + if (!selectedTicket) return; + try { + await api.put(`/tickets/admin/${selectedTicket.id}/status`, { status }); + toast.success('وضعیت تیکت تغییر یافت'); + setSelectedTicket({ ...selectedTicket, status: status as any }); + fetchTickets(); + } catch { + toast.error('خطا در تغییر وضعیت تیکت'); + } + }; + + const getCategoryBadge = (cat: string) => { + switch (cat) { + case 'VET_CONSULTATION': + return { label: 'مشاوره دامپزشک', bg: 'bg-emerald-50 text-emerald-700 border-emerald-200', icon: Stethoscope }; + case 'ORDER_SUPPORT': + return { label: 'پشتیبانی سفارش', bg: 'bg-blue-50 text-blue-700 border-blue-200', icon: FileText }; + case 'PRODUCT_INQUIRY': + return { label: 'سوال محصولی / دارویی', bg: 'bg-purple-50 text-purple-700 border-purple-200', icon: Building2 }; + default: + return { label: 'عمومی', bg: 'bg-gray-50 text-gray-700 border-gray-200', icon: MessageSquare }; + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'OPEN': + return { label: 'در انتظار پاسخ', bg: 'bg-amber-50 text-amber-700 border-amber-200' }; + case 'ANSWERED': + return { label: 'پاسخ داده شد', bg: 'bg-emerald-50 text-emerald-700 border-emerald-200' }; + case 'IN_PROGRESS': + return { label: 'در حال بررسی', bg: 'bg-blue-50 text-blue-700 border-blue-200' }; + case 'CLOSED': + return { label: 'بسته شده', bg: 'bg-gray-100 text-gray-600 border-gray-200' }; + default: + return { label: status, bg: 'bg-gray-50 text-gray-600 border-gray-200' }; + } + }; + + return ( +
+ {/* Header */} +
+
+

+ + پشتیبانی، تیکتینگ و مشاوره دامپزشکی +

+

+ مشاهده و پاسخ‌دهی به تیکت‌های پزشکی، مشاوره‌ای و پشتیبانی مشتریان کنینا +

+
+ +
+ + {/* Filters & Search */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-4 pr-10 py-2.5 rounded-xl border border-gray-200 text-xs font-medium focus:border-canina-blue outline-none" + /> +
+ +
+ + + +
+
+
+ + {/* Tickets Table */} +
+
+ + + + + + + + + + + + + + {loading && tickets.length === 0 ? ( + + + + ) : tickets.length === 0 ? ( + + + + ) : ( + tickets.map((t) => { + const cat = getCategoryBadge(t.category); + const st = getStatusBadge(t.status); + const userName = `${t.user?.firstName || ''} ${t.user?.lastName || ''}`.trim() || 'بدون نام'; + + return ( + + + + + + + + + + ); + }) + )} + +
شماره تیکتکاربر / صاحب پتپت مرتبطموضوع و دسته‌بندیوضعیتآخرین بروزرسانیعملیات
+ در حال دریافت تیکت‌ها... +
+ تیکتی مطابق با فیلترهای انتخابی یافت نشد. +
+ {t.ticketNumber} + +
{userName}
+
{t.user?.mobile}
+
+ {t.pet ? ( + + 🐾 {t.pet.name} ({t.pet.breed || t.pet.type}) + + ) : ( + - + )} + +
{t.subject}
+ + + {cat.label} + +
+ + {st.label} + + + {new Date(t.updatedAt).toLocaleDateString('fa-IR')} + + +
+
+ + {/* Pagination */} +
+
تعداد کل تیکت‌ها: {total}
+
+ + صفحه {page} + +
+
+
+ + {/* Ticket Details & Admin Reply Modal */} + {selectedTicket && ( +
+
+ {/* Modal Header */} +
+
+
+

{selectedTicket.subject}

+ + {selectedTicket.ticketNumber} + +
+
+ کاربر: {selectedTicket.user?.firstName} {selectedTicket.user?.lastName} ({selectedTicket.user?.mobile}) + {selectedTicket.pet && ( + • 🐾 {selectedTicket.pet.name} ({selectedTicket.pet.breed || selectedTicket.pet.type}) + )} +
+
+
+ + +
+
+ + {/* Messages Chat Scroll */} +
+ {(selectedTicket.messages || []).map((msg) => { + const isAdminOrVet = msg.senderRole === 'ADMIN' || msg.senderRole === 'VET_DOCTOR'; + return ( +
+
+ + {isAdminOrVet ? ( + <> + + {msg.senderName} (تیم پزشکی / ادمین) + + ) : ( + <> + + {msg.senderName} (مشتری) + + )} + + + {new Date(msg.createdAt).toLocaleString('fa-IR')} + +
+

{msg.message}

+
+ ); + })} +
+ + {/* Admin Response Composer */} +
+
+ +
+ تغییر وضعیت به: + +
+
+