feat(sms,admin): implement full sms logging, tracking, filtering, and analytics system
This commit is contained in:
parent
6a476fab1a
commit
d8c6aa8d8e
@ -583,3 +583,23 @@ model Setting {
|
||||
@@index([category])
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
model SmsLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
receptor String @db.VarChar(20)
|
||||
type String @db.VarChar(50) // OTP, ORDER_CONFIRMATION, SHIPPING_TRACKING, B2B_NOTIFICATION, PET_CARE_REMINDER, TEST, GENERIC
|
||||
patternId Int? @map("pattern_id")
|
||||
args String[] @default([])
|
||||
messageText String? @map("message_text") @db.Text
|
||||
status String @db.VarChar(20) // SUCCESS, FAILED, DISABLED
|
||||
recId String? @map("rec_id") @db.VarChar(50)
|
||||
errorMessage String? @map("error_message") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
|
||||
@@index([receptor])
|
||||
@@index([type])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@map("sms_logs")
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as https from 'https';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export interface SendPatternSmsOptions {
|
||||
to: string;
|
||||
bodyId: number; // MeliPayamak Shared Pattern Body ID
|
||||
args: string[]; // Dynamic variables inside pattern
|
||||
type?: string; // OTP, ORDER_CONFIRMATION, SHIPPING_TRACKING, B2B_NOTIFICATION, PET_CARE_REMINDER, TEST, GENERIC
|
||||
}
|
||||
|
||||
export interface SmsConfig {
|
||||
@ -29,6 +31,16 @@ export interface MeliPayamakPattern {
|
||||
assignedTo?: string[];
|
||||
}
|
||||
|
||||
export interface SmsLogQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface MeliPayamakResponse {
|
||||
Value?: number;
|
||||
RetStatus?: number;
|
||||
@ -108,6 +120,108 @@ export class SmsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to write an SMS Log to PostgreSQL safely
|
||||
*/
|
||||
private async recordLog(entry: {
|
||||
receptor: string;
|
||||
type: string;
|
||||
patternId?: number;
|
||||
args?: string[];
|
||||
messageText?: string;
|
||||
status: string;
|
||||
recId?: string;
|
||||
errorMessage?: string;
|
||||
}) {
|
||||
try {
|
||||
await this.prisma.smsLog.create({
|
||||
data: {
|
||||
receptor: entry.receptor,
|
||||
type: entry.type || 'GENERIC',
|
||||
patternId: entry.patternId || null,
|
||||
args: entry.args || [],
|
||||
messageText: entry.messageText || null,
|
||||
status: entry.status,
|
||||
recId: entry.recId ? String(entry.recId) : null,
|
||||
errorMessage: entry.errorMessage || null,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[SMS Log Save Failed]: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query paginated SMS Logs with Search, Filter and Sorting
|
||||
*/
|
||||
async getSmsLogs(query: SmsLogQuery = {}) {
|
||||
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.SmsLogWhereInput = {};
|
||||
|
||||
if (query.search) {
|
||||
const s = query.search.trim();
|
||||
where.OR = [
|
||||
{ receptor: { contains: s, mode: 'insensitive' } },
|
||||
{ recId: { contains: s, mode: 'insensitive' } },
|
||||
{ errorMessage: { contains: s, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (query.type && query.type !== 'ALL') {
|
||||
where.type = query.type;
|
||||
}
|
||||
|
||||
if (query.status && query.status !== 'ALL') {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
const sortField = query.sortBy || 'createdAt';
|
||||
const sortOrder = query.sortOrder === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
const [logs, total, totalSuccess, totalFailed] = await Promise.all([
|
||||
this.prisma.smsLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: sortOrder },
|
||||
}),
|
||||
this.prisma.smsLog.count({ where }),
|
||||
this.prisma.smsLog.count({ where: { status: 'SUCCESS' } }),
|
||||
this.prisma.smsLog.count({ where: { status: 'FAILED' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
stats: {
|
||||
total,
|
||||
success: totalSuccess,
|
||||
failed: totalFailed,
|
||||
successRate: total > 0 ? Math.round((totalSuccess / total) * 100) : 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific SMS log entry
|
||||
*/
|
||||
async deleteSmsLog(id: string) {
|
||||
return this.prisma.smsLog.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all SMS logs
|
||||
*/
|
||||
async clearAllSmsLogs() {
|
||||
return this.prisma.smsLog.deleteMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XML response for GetSharedServiceBody into structured pattern array
|
||||
*/
|
||||
@ -241,7 +355,6 @@ export class SmsService {
|
||||
const code = match ? parseInt(match[1], 10) : 0;
|
||||
|
||||
if (code > 0) {
|
||||
// Store locally
|
||||
await this.saveLocalPattern({
|
||||
id: code,
|
||||
title,
|
||||
@ -369,18 +482,32 @@ export class SmsService {
|
||||
*/
|
||||
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
||||
const config = await this.getSmsConfig();
|
||||
const msgType = options.type || 'GENERIC_PATTERN';
|
||||
|
||||
if (!config.enabled) {
|
||||
this.logger.warn(`[SMS Disabled] SMS dispatch skipped for ${options.to}`);
|
||||
await this.recordLog({
|
||||
receptor: options.to,
|
||||
type: msgType,
|
||||
patternId: options.bodyId,
|
||||
args: options.args,
|
||||
status: 'DISABLED',
|
||||
errorMessage: 'ارسال پیامک از پنل ادمین غیرفعال شده است.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!config.username || !config.password) {
|
||||
this.logger.error(
|
||||
`[SMS MISCONFIGURED] MELIPAYAMAK username/password is not configured! ` +
|
||||
`SMS to ${options.to} (Pattern: ${options.bodyId}) was NOT sent. ` +
|
||||
`Please configure MeliPayamak settings in Admin Panel.`,
|
||||
);
|
||||
const err = 'مشخصات نام کاربری و رمز عبور سامانه پیامک تنظیم نشده است.';
|
||||
this.logger.error(`[SMS MISCONFIGURED] ${err}`);
|
||||
await this.recordLog({
|
||||
receptor: options.to,
|
||||
type: msgType,
|
||||
patternId: options.bodyId,
|
||||
args: options.args,
|
||||
status: 'FAILED',
|
||||
errorMessage: err,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -405,7 +532,7 @@ export class SmsService {
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
res.on('end', async () => {
|
||||
try {
|
||||
const json = JSON.parse(data) as MeliPayamakResponse;
|
||||
const val = json.Value ?? 0;
|
||||
@ -413,24 +540,54 @@ export class SmsService {
|
||||
this.logger.log(
|
||||
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`,
|
||||
);
|
||||
await this.recordLog({
|
||||
receptor: options.to,
|
||||
type: msgType,
|
||||
patternId: options.bodyId,
|
||||
args: options.args,
|
||||
status: 'SUCCESS',
|
||||
recId: String(val),
|
||||
});
|
||||
resolve(true);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`[SMS Error] Failed sending pattern SMS to ${options.to}. Code: ${val}`,
|
||||
);
|
||||
const errMsg = `خطای درگاه ملی پیامک با کد بازگشتی: ${val}`;
|
||||
this.logger.error(`[SMS Error] Failed sending to ${options.to}. Code: ${val}`);
|
||||
await this.recordLog({
|
||||
receptor: options.to,
|
||||
type: msgType,
|
||||
patternId: options.bodyId,
|
||||
args: options.args,
|
||||
status: 'FAILED',
|
||||
recId: String(val),
|
||||
errorMessage: errMsg,
|
||||
});
|
||||
resolve(false);
|
||||
}
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
await this.recordLog({
|
||||
receptor: options.to,
|
||||
type: msgType,
|
||||
patternId: options.bodyId,
|
||||
args: options.args,
|
||||
status: 'FAILED',
|
||||
errorMessage: `خطای پارس پاسخ سرور: ${data || err.message}`,
|
||||
});
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (err) => {
|
||||
this.logger.error(
|
||||
`[SMS Exception] MeliPayamak HTTP Error: ${err.message}`,
|
||||
);
|
||||
req.on('error', async (err) => {
|
||||
this.logger.error(`[SMS Exception] MeliPayamak HTTP Error: ${err.message}`);
|
||||
await this.recordLog({
|
||||
receptor: options.to,
|
||||
type: msgType,
|
||||
patternId: options.bodyId,
|
||||
args: options.args,
|
||||
status: 'FAILED',
|
||||
errorMessage: `خطای شبکه: ${err.message}`,
|
||||
});
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
@ -446,10 +603,16 @@ export class SmsService {
|
||||
const config = await this.getSmsConfig();
|
||||
|
||||
if (!config.username || !config.password) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'نام کاربری یا رمز عبور سامانه ملی پیامک تنظیم نشده است.',
|
||||
};
|
||||
const msg = 'نام کاربری یا رمز عبور سامانه ملی پیامک تنظیم نشده است.';
|
||||
await this.recordLog({
|
||||
receptor: targetPhone,
|
||||
type: 'TEST',
|
||||
patternId: testPatternId,
|
||||
args: testArgs,
|
||||
status: 'FAILED',
|
||||
errorMessage: msg,
|
||||
});
|
||||
return { success: false, message: msg };
|
||||
}
|
||||
|
||||
const bodyId = testPatternId || config.otpBodyId || 508079;
|
||||
@ -476,38 +639,68 @@ export class SmsService {
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
res.on('end', async () => {
|
||||
try {
|
||||
const json = JSON.parse(data) as MeliPayamakResponse;
|
||||
const val = json.Value ?? 0;
|
||||
if (json && (val > 15 || json.RetStatus === 1)) {
|
||||
await this.recordLog({
|
||||
receptor: targetPhone,
|
||||
type: 'TEST',
|
||||
patternId: bodyId,
|
||||
args,
|
||||
status: 'SUCCESS',
|
||||
recId: String(val),
|
||||
});
|
||||
resolve({
|
||||
success: true,
|
||||
message: `پیامک تستی پترن با موفقیت ارسال شد (شناسه پیگیری ملی پیامک: ${val})`,
|
||||
rawResponse: json,
|
||||
});
|
||||
} else {
|
||||
const errMsg = `خطای درگاه ملی پیامک: کد پاسخ بازگشتی ${val}`;
|
||||
await this.recordLog({
|
||||
receptor: targetPhone,
|
||||
type: 'TEST',
|
||||
patternId: bodyId,
|
||||
args,
|
||||
status: 'FAILED',
|
||||
recId: String(val),
|
||||
errorMessage: errMsg,
|
||||
});
|
||||
resolve({
|
||||
success: false,
|
||||
message: `خطای درگاه ملی پیامک: کد پاسخ بازگشتی ${val}`,
|
||||
message: errMsg,
|
||||
rawResponse: json,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
resolve({
|
||||
success: false,
|
||||
message: `پاسخ نامعتبر از سرور ملی پیامک: ${data || err.message}`,
|
||||
const errMsg = `پاسخ نامعتبر از سرور ملی پیامک: ${data || err.message}`;
|
||||
await this.recordLog({
|
||||
receptor: targetPhone,
|
||||
type: 'TEST',
|
||||
patternId: bodyId,
|
||||
args,
|
||||
status: 'FAILED',
|
||||
errorMessage: errMsg,
|
||||
});
|
||||
resolve({ success: false, message: errMsg });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (err) => {
|
||||
resolve({
|
||||
success: false,
|
||||
message: `خطای برقراری ارتباط با وبسرویس ملی پیامک: ${err.message}`,
|
||||
req.on('error', async (err) => {
|
||||
const errMsg = `خطای برقراری ارتباط با وبسرویس ملی پیامک: ${err.message}`;
|
||||
await this.recordLog({
|
||||
receptor: targetPhone,
|
||||
type: 'TEST',
|
||||
patternId: bodyId,
|
||||
args,
|
||||
status: 'FAILED',
|
||||
errorMessage: errMsg,
|
||||
});
|
||||
resolve({ success: false, message: errMsg });
|
||||
});
|
||||
|
||||
req.write(payload);
|
||||
@ -524,6 +717,7 @@ export class SmsService {
|
||||
to: phone,
|
||||
bodyId: config.otpBodyId,
|
||||
args: [otpCode],
|
||||
type: 'OTP',
|
||||
});
|
||||
}
|
||||
|
||||
@ -540,6 +734,7 @@ export class SmsService {
|
||||
to: phone,
|
||||
bodyId: config.orderBodyId,
|
||||
args: [orderNumber, amount],
|
||||
type: 'ORDER_CONFIRMATION',
|
||||
});
|
||||
}
|
||||
|
||||
@ -556,6 +751,7 @@ export class SmsService {
|
||||
to: phone,
|
||||
bodyId: config.shippingBodyId,
|
||||
args: [orderNumber, trackingCode],
|
||||
type: 'SHIPPING_TRACKING',
|
||||
});
|
||||
}
|
||||
|
||||
@ -571,6 +767,7 @@ export class SmsService {
|
||||
to: phone,
|
||||
bodyId: config.b2bBodyId,
|
||||
args: [applicantName],
|
||||
type: 'B2B_NOTIFICATION',
|
||||
});
|
||||
}
|
||||
|
||||
@ -588,6 +785,7 @@ export class SmsService {
|
||||
to: phone,
|
||||
bodyId: config.petCareBodyId,
|
||||
args: [petName, reminderType],
|
||||
type: 'PET_CARE_REMINDER',
|
||||
});
|
||||
}
|
||||
|
||||
@ -596,8 +794,23 @@ export class SmsService {
|
||||
*/
|
||||
async sendSms(phone: string, message: string): Promise<boolean> {
|
||||
const config = await this.getSmsConfig();
|
||||
if (!config.enabled) return false;
|
||||
if (!config.enabled) {
|
||||
await this.recordLog({
|
||||
receptor: phone,
|
||||
type: 'GENERIC_TEXT',
|
||||
messageText: message,
|
||||
status: 'DISABLED',
|
||||
errorMessage: 'ارسال پیامک غیرفعال است.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this.logger.log(`[SMS Text Sent] To: ${phone}, Content: ${message}`);
|
||||
await this.recordLog({
|
||||
receptor: phone,
|
||||
type: 'GENERIC_TEXT',
|
||||
messageText: message,
|
||||
status: 'SUCCESS',
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@ -192,5 +193,34 @@ export class SettingsController {
|
||||
) {
|
||||
return this.settingsService.editPattern(Number(bodyId), body);
|
||||
}
|
||||
|
||||
// SMS Logs & Tracking Endpoints
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Get('sms/logs')
|
||||
@ApiOperation({ summary: 'دریافت گزارشات و لاگهای کامل پیامکهای ارسالی با فیلتر و جستجو' })
|
||||
getSmsLogs(@Query() query: any) {
|
||||
return this.settingsService.getSmsLogs(query);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Delete('sms/logs/:id')
|
||||
@ApiOperation({ summary: 'حذف یک رکورد لاگ پیامک' })
|
||||
deleteSmsLog(@Param('id') id: string) {
|
||||
return this.settingsService.deleteSmsLog(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Delete('sms/logs')
|
||||
@ApiOperation({ summary: 'پاکسازی تمامی لاگهای پیامک' })
|
||||
clearAllSmsLogs() {
|
||||
return this.settingsService.clearAllSmsLogs();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -115,5 +115,17 @@ export class SettingsService {
|
||||
async editPattern(bodyId: number, body: string) {
|
||||
return this.smsService.editPattern(bodyId, body);
|
||||
}
|
||||
|
||||
async getSmsLogs(query: any) {
|
||||
return this.smsService.getSmsLogs(query);
|
||||
}
|
||||
|
||||
async deleteSmsLog(id: string) {
|
||||
return this.smsService.deleteSmsLog(id);
|
||||
}
|
||||
|
||||
async clearAllSmsLogs() {
|
||||
return this.smsService.clearAllSmsLogs();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Save,
|
||||
@ -25,6 +25,15 @@ import {
|
||||
AlertTriangle,
|
||||
X,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
Search,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
RotateCcw,
|
||||
Info,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
@ -51,9 +60,30 @@ interface PatternItem {
|
||||
assignedTo?: string[];
|
||||
}
|
||||
|
||||
export default function SmsSettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'patterns'>('settings');
|
||||
interface SmsLogItem {
|
||||
id: string;
|
||||
receptor: string;
|
||||
type: string;
|
||||
patternId?: number | null;
|
||||
args: string[];
|
||||
messageText?: string | null;
|
||||
status: 'SUCCESS' | 'FAILED' | 'DISABLED';
|
||||
recId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SmsLogStats {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export default function SmsSettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'patterns' | 'logs'>('settings');
|
||||
|
||||
// Config State
|
||||
const [config, setConfig] = useState<SmsConfigState>({
|
||||
enabled: true,
|
||||
username: '',
|
||||
@ -73,7 +103,7 @@ export default function SmsSettingsPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
// Modals
|
||||
// Pattern Modals
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [newPatternTitle, setNewPatternTitle] = useState('');
|
||||
@ -82,6 +112,24 @@ export default function SmsSettingsPage() {
|
||||
const [editingPattern, setEditingPattern] = useState<PatternItem | null>(null);
|
||||
const [editPatternBody, setEditPatternBody] = useState('');
|
||||
|
||||
// Logs State
|
||||
const [logs, setLogs] = useState<SmsLogItem[]>([]);
|
||||
const [logStats, setLogStats] = useState<SmsLogStats>({
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
successRate: 100,
|
||||
});
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const [logTotalPages, setLogTotalPages] = useState(1);
|
||||
const [logSearch, setLogSearch] = useState('');
|
||||
const [logTypeFilter, setLogTypeFilter] = useState('ALL');
|
||||
const [logStatusFilter, setLogStatusFilter] = useState('ALL');
|
||||
const [logSortOrder, setLogSortOrder] = useState<'desc' | 'asc'>('desc');
|
||||
const [selectedLog, setSelectedLog] = useState<SmsLogItem | null>(null);
|
||||
const [isLogDetailModalOpen, setIsLogDetailModalOpen] = useState(false);
|
||||
|
||||
// Test SMS State
|
||||
const [testPhone, setTestPhone] = useState('');
|
||||
const [testPatternType, setTestPatternType] = useState('otp');
|
||||
@ -130,10 +178,47 @@ export default function SmsSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
setIsLoadingLogs(true);
|
||||
const params: Record<string, any> = {
|
||||
page: logPage,
|
||||
limit: 15,
|
||||
sortOrder: logSortOrder,
|
||||
};
|
||||
|
||||
if (logSearch) params.search = logSearch;
|
||||
if (logTypeFilter !== 'ALL') params.type = logTypeFilter;
|
||||
if (logStatusFilter !== 'ALL') params.status = logStatusFilter;
|
||||
|
||||
const res = await api.get('/settings/sms/logs', { params });
|
||||
const data = res.data?.data || res.data;
|
||||
|
||||
if (data) {
|
||||
setLogs(data.logs || []);
|
||||
setLogTotalPages(data.totalPages || 1);
|
||||
if (data.stats) {
|
||||
setLogStats(data.stats);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch SMS logs:', err);
|
||||
toast.error('خطا در دریافت لاگهای پیامک');
|
||||
} finally {
|
||||
setIsLoadingLogs(false);
|
||||
}
|
||||
}, [logPage, logSearch, logTypeFilter, logStatusFilter, logSortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettingsAndPatterns();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs') {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [activeTab, fetchLogs]);
|
||||
|
||||
const refreshPatterns = async () => {
|
||||
try {
|
||||
setIsLoadingPatterns(true);
|
||||
@ -251,11 +336,31 @@ export default function SmsSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (id: number) => {
|
||||
navigator.clipboard.writeText(String(id));
|
||||
setCopiedId(id);
|
||||
toast.success(`کد پترن ${id} کپی شد`);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
const handleDeleteLog = async (id: string) => {
|
||||
if (!window.confirm('آیا از حذف این لاگ پیامک اطمینان دارید؟')) return;
|
||||
try {
|
||||
await api.delete(`/settings/sms/logs/${id}`);
|
||||
toast.success('لاگ با موفقیت حذف شد');
|
||||
fetchLogs();
|
||||
} catch {
|
||||
toast.error('خطا در حذف لاگ');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllLogs = async () => {
|
||||
if (!window.confirm('آیا از پاکسازی تمامی لاگهای پیامک اطمینان دارید؟ این عمل غیرقابل بازگشت است.')) return;
|
||||
try {
|
||||
await api.delete('/settings/sms/logs');
|
||||
toast.success('تمام لاگهای پیامک پاکسازی شدند');
|
||||
fetchLogs();
|
||||
} catch {
|
||||
toast.error('خطا در پاکسازی لاگها');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string | number, label: string = 'کد') => {
|
||||
navigator.clipboard.writeText(String(text));
|
||||
toast.success(`${label} کپی شد`);
|
||||
};
|
||||
|
||||
const handleSendTestSms = async (e: React.FormEvent) => {
|
||||
@ -313,6 +418,41 @@ export default function SmsSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeName = (type: string) => {
|
||||
switch (type) {
|
||||
case 'OTP':
|
||||
return { label: 'کد تایید OTP', bg: 'bg-purple-100 text-purple-800 border-purple-200' };
|
||||
case 'ORDER_CONFIRMATION':
|
||||
return { label: 'ثبت و تایید سفارش', bg: 'bg-emerald-100 text-emerald-800 border-emerald-200' };
|
||||
case 'SHIPPING_TRACKING':
|
||||
return { label: 'کد رهگیری پست', bg: 'bg-blue-100 text-blue-800 border-blue-200' };
|
||||
case 'B2B_NOTIFICATION':
|
||||
return { label: 'همکاران B2B', bg: 'bg-amber-100 text-amber-800 border-amber-200' };
|
||||
case 'PET_CARE_REMINDER':
|
||||
return { label: 'یادآور سلامت پت', bg: 'bg-pink-100 text-pink-800 border-pink-200' };
|
||||
case 'TEST':
|
||||
return { label: 'پیامک تستی', bg: 'bg-slate-100 text-slate-800 border-slate-200' };
|
||||
default:
|
||||
return { label: type || 'متنی آزاد', bg: 'bg-gray-100 text-gray-800 border-gray-200' };
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
@ -320,31 +460,32 @@ export default function SmsSettingsPage() {
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-6 h-6 text-purple-600" />
|
||||
مدیریت درگاه و الگوهای پیامک (MeliPayamak)
|
||||
مرکز پیامک و اطلاعرسانی (MeliPayamak SMS Suite)
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">
|
||||
پیکربندی اتصال وبسرویس، مدیریت CRUD الگوهای خدماتی و ابزار ارسال آزمایشی
|
||||
پیکربندی اتصال وبسرویس، مدیریت الگوهای خدماتی و رهگیری لاگهای لحظهای ارسال
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Toggle Buttons */}
|
||||
<div className="flex items-center bg-gray-100 p-1 rounded-2xl border border-gray-200">
|
||||
{/* Tab Toggle Navigation */}
|
||||
<div className="flex items-center bg-gray-100 p-1 rounded-2xl border border-gray-200 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 ${
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||
activeTab === 'settings'
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
پیکربندی و کدهای فعال
|
||||
پیکربندی حساب
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('patterns')}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 ${
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||
activeTab === 'patterns'
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
@ -353,11 +494,29 @@ export default function SmsSettingsPage() {
|
||||
<Layers className="w-4 h-4" />
|
||||
مدیریت الگوها (Patterns)
|
||||
{patterns.length > 0 && (
|
||||
<span className="bg-purple-100 text-purple-800 text-[10px] px-2 py-0.5 rounded-full font-mono font-black">
|
||||
<span className="bg-purple-100 text-purple-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
|
||||
{patterns.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||
activeTab === 'logs'
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
لاگها و رهگیری ارسال
|
||||
{logStats.total > 0 && (
|
||||
<span className="bg-purple-100 text-purple-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
|
||||
{logStats.total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -683,7 +842,7 @@ export default function SmsSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : activeTab === 'patterns' ? (
|
||||
/* Patterns Management Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Action Bar */}
|
||||
@ -745,7 +904,11 @@ export default function SmsSettingsPage() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(p.id)}
|
||||
onClick={() => {
|
||||
copyToClipboard(p.id, 'کد پترن');
|
||||
setCopiedId(p.id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
}}
|
||||
className="text-gray-400 hover:text-purple-600 p-1"
|
||||
title="کپی شناسه"
|
||||
>
|
||||
@ -800,7 +963,6 @@ export default function SmsSettingsPage() {
|
||||
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 flex-wrap">
|
||||
{/* Fast Bind Dropdown */}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
@ -843,6 +1005,317 @@ export default function SmsSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Logs & Tracking Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-50 flex items-center justify-center text-purple-600">
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 font-medium">کل پیامکهای ارسالی</span>
|
||||
<h4 className="text-xl font-black text-gray-900 font-mono mt-0.5">{logStats.total}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 font-medium">ارسالهای موفق</span>
|
||||
<h4 className="text-xl font-black text-emerald-600 font-mono mt-0.5">{logStats.success}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-rose-50 flex items-center justify-center text-rose-600">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 font-medium">ارسالهای ناموفق / خطا</span>
|
||||
<h4 className="text-xl font-black text-rose-600 font-mono mt-0.5">{logStats.failed}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
|
||||
<TrendingUp className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 font-medium">نرخ موفقیت تحویل</span>
|
||||
<h4 className="text-xl font-black text-blue-600 font-mono mt-0.5">{logStats.successRate}%</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Actions Bar */}
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative md:col-span-2">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={logSearch}
|
||||
onChange={(e) => {
|
||||
setLogSearch(e.target.value);
|
||||
setLogPage(1);
|
||||
}}
|
||||
placeholder="جستجو در شماره گیرنده، کد پیگیری یا متن خطا..."
|
||||
className="w-full pr-9 pl-3 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={logTypeFilter}
|
||||
onChange={(e) => {
|
||||
setLogTypeFilter(e.target.value);
|
||||
setLogPage(1);
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded-xl p-2 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-bold bg-white"
|
||||
>
|
||||
<option value="ALL">همه انواع پیامک</option>
|
||||
<option value="OTP">کد تایید ورود (OTP)</option>
|
||||
<option value="ORDER_CONFIRMATION">ثبت و تایید سفارش</option>
|
||||
<option value="SHIPPING_TRACKING">کد رهگیری پست</option>
|
||||
<option value="B2B_NOTIFICATION">همکاران B2B</option>
|
||||
<option value="PET_CARE_REMINDER">یادآور سلامت پت</option>
|
||||
<option value="TEST">پیامک تستی</option>
|
||||
<option value="GENERIC_TEXT">متن آزاد</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={logStatusFilter}
|
||||
onChange={(e) => {
|
||||
setLogStatusFilter(e.target.value);
|
||||
setLogPage(1);
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded-xl p-2 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-bold bg-white"
|
||||
>
|
||||
<option value="ALL">همه وضعیتها</option>
|
||||
<option value="SUCCESS">موفق (SUCCESS)</option>
|
||||
<option value="FAILED">ناموفق (FAILED)</option>
|
||||
<option value="DISABLED">غیرفعال (DISABLED)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 flex-wrap gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoadingLogs}
|
||||
className="p-2 text-gray-600 hover:text-purple-600 hover:bg-purple-50 rounded-xl border border-gray-200 flex items-center gap-1.5 font-bold"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingLogs ? 'animate-spin text-purple-600' : ''}`} />
|
||||
<span>بروزرسانی لاگها</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogSortOrder(logSortOrder === 'desc' ? 'asc' : 'desc')}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded-xl border border-gray-200 flex items-center gap-1 font-bold"
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
<span>مرتبسازی: {logSortOrder === 'desc' ? 'جدیدترین' : 'قدیمیترین'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAllLogs}
|
||||
className="text-rose-600 hover:bg-rose-50 border border-rose-200 px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
پاکسازی کل لاگها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<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 text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600 font-bold border-b border-gray-200 text-xs">
|
||||
<tr>
|
||||
<th className="p-4">شماره گیرنده</th>
|
||||
<th className="p-4">نوع پیامک</th>
|
||||
<th className="p-4">کد پترن / متغیرها</th>
|
||||
<th className="p-4">شناسه پیگیری (RecId)</th>
|
||||
<th className="p-4">وضعیت</th>
|
||||
<th className="p-4">تاریخ و ساعت</th>
|
||||
<th className="p-4 text-center">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 font-medium">
|
||||
{isLoadingLogs ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-gray-400">
|
||||
<div className="flex justify-center"><Spinner size="md" className="text-purple-600" /></div>
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-gray-400 text-xs">
|
||||
هیچ رکورد لاگ پیامکی متناسب با جستجو یافت نشد.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const typeInfo = getTypeName(log.type);
|
||||
return (
|
||||
<tr key={log.id} className="hover:bg-purple-50/30 transition-colors">
|
||||
<td className="p-4 font-mono font-bold text-gray-900" dir="ltr">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span>{log.receptor}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(log.receptor, 'شماره')}
|
||||
className="text-gray-400 hover:text-purple-600"
|
||||
title="کپی شماره"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="p-4">
|
||||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold border ${typeInfo.bg}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="p-4 text-xs font-mono">
|
||||
{log.patternId ? (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded font-bold">
|
||||
#{log.patternId}
|
||||
</span>
|
||||
{log.args && log.args.length > 0 && (
|
||||
<span className="text-gray-500 font-sans text-[11px]">
|
||||
[{log.args.join(' , ')}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 font-sans">متنی آزاد</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-4 text-xs font-mono font-bold text-gray-700">
|
||||
{log.recId ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{log.recId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(log.recId || '', 'شناسه پیگیری')}
|
||||
className="text-gray-400 hover:text-purple-600"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-300">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-4 text-xs">
|
||||
{log.status === 'SUCCESS' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
موفق
|
||||
</span>
|
||||
) : log.status === 'DISABLED' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-slate-50 text-slate-700 border border-slate-200">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
غیرفعال
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-rose-50 text-rose-700 border border-rose-200"
|
||||
title={log.errorMessage || 'خطا در ارسال'}
|
||||
>
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
ناموفق
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-4 text-xs text-gray-500 font-mono">
|
||||
{formatDate(log.createdAt)}
|
||||
</td>
|
||||
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLog(log);
|
||||
setIsLogDetailModalOpen(true);
|
||||
}}
|
||||
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
title="مشاهده جزییات"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="حذف لاگ"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{logTotalPages > 1 && (
|
||||
<div className="p-4 bg-gray-50 border-t border-gray-100 flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500 font-medium">
|
||||
صفحه {logPage} از {logTotalPages}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={logPage <= 1}
|
||||
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={logPage >= logTotalPages}
|
||||
onClick={() => setLogPage((p) => Math.min(logTotalPages, p + 1))}
|
||||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Add New Pattern */}
|
||||
@ -1011,6 +1484,104 @@ export default function SmsSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Log Details */}
|
||||
{isLogDetailModalOpen && selectedLog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white w-full max-w-lg rounded-2xl shadow-2xl border border-gray-100 overflow-hidden font-vazir animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
||||
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
|
||||
<Info className="w-5 h-5 text-purple-600" />
|
||||
جزییات پیامک ارسالی
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogDetailModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 p-1 rounded-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 text-xs">
|
||||
<div className="grid grid-cols-2 gap-3 bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<span className="text-gray-400 font-medium">شماره گیرنده:</span>
|
||||
<p className="font-mono font-bold text-gray-900 mt-0.5">{selectedLog.receptor}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-medium">نوع پیامک:</span>
|
||||
<p className="font-bold text-gray-900 mt-0.5">{getTypeName(selectedLog.type).label}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-medium">شناسه الگو (Pattern ID):</span>
|
||||
<p className="font-mono font-bold text-gray-900 mt-0.5">
|
||||
{selectedLog.patternId ? `#${selectedLog.patternId}` : 'ارسال مستقیم'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-medium">کد رهگیری ملی پیامک:</span>
|
||||
<p className="font-mono font-bold text-gray-900 mt-0.5">
|
||||
{selectedLog.recId || 'ثبت نشده'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-medium">وضعیت ارسال:</span>
|
||||
<p className="font-bold mt-0.5">
|
||||
{selectedLog.status === 'SUCCESS' ? (
|
||||
<span className="text-emerald-600">ارسال موفق (SUCCESS)</span>
|
||||
) : (
|
||||
<span className="text-rose-600">{selectedLog.status}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-medium">تاریخ و زمان ثبت:</span>
|
||||
<p className="font-mono font-bold text-gray-900 mt-0.5">{formatDate(selectedLog.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLog.args && selectedLog.args.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold text-gray-700">آرگومانها و مقادیر ارسالی به پترن:</span>
|
||||
<div className="bg-slate-50 p-3 rounded-xl border border-slate-200 font-mono text-[11px] space-y-1">
|
||||
{selectedLog.args.map((arg, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className="text-purple-600 font-bold">{`{${idx}}`}:</span>
|
||||
<span className="text-gray-800 font-sans">{arg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.errorMessage && (
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold text-rose-700">توضیحات خطا / پاسخ درگاه:</span>
|
||||
<div className="bg-rose-50 text-rose-800 p-3 rounded-xl border border-rose-200 text-[11px] leading-relaxed">
|
||||
{selectedLog.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogDetailModalOpen(false)}
|
||||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-xs font-bold text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
بستن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user