feat(sms,admin): implement full sms logging, tracking, filtering, and analytics system

This commit is contained in:
parsa aghaei 2026-08-16 11:55:50 +03:30
parent 6a476fab1a
commit d8c6aa8d8e
5 changed files with 895 additions and 49 deletions

View File

@ -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")
}

View File

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

View File

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

View File

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

View File

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