feat(admin,sms): implement full pattern management CRUD and live binding directly from admin panel
This commit is contained in:
parent
09fd339e89
commit
0934b4f3af
@ -20,6 +20,15 @@ export interface SmsConfig {
|
||||
petCareBodyId: number;
|
||||
}
|
||||
|
||||
export interface MeliPayamakPattern {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
status: number; // 0 = Pending, 1 = Approved, 2 = Needs Edit
|
||||
statusText?: string;
|
||||
assignedTo?: string[];
|
||||
}
|
||||
|
||||
interface MeliPayamakResponse {
|
||||
Value?: number;
|
||||
RetStatus?: number;
|
||||
@ -68,6 +77,293 @@ export class SmsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper HTTP POST request to Payamak-Panel ASMX endpoints
|
||||
*/
|
||||
private async postAsmx(endpoint: string, params: Record<string, string | number>): Promise<string> {
|
||||
const postData = Object.entries(params)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
`https://api.payamak-panel.com/post/SharedService.asmx/${endpoint}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => resolve(data));
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (err) => reject(err));
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XML response for GetSharedServiceBody into structured pattern array
|
||||
*/
|
||||
private parsePatternsXml(xml: string): MeliPayamakPattern[] {
|
||||
const patterns: MeliPayamakPattern[] = [];
|
||||
const itemRegex = /<SharedServiceBody>([\s\S]*?)<\/SharedServiceBody>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = itemRegex.exec(xml)) !== null) {
|
||||
const block = match[1];
|
||||
const idMatch = block.match(/<Id>(\d+)<\/Id>/i);
|
||||
const titleMatch = block.match(/<Title>([\s\S]*?)<\/Title>/i);
|
||||
const bodyMatch = block.match(/<Body>([\s\S]*?)<\/Body>/i);
|
||||
const statusMatch = block.match(/<Status>(-?\d+)<\/Status>/i);
|
||||
|
||||
if (idMatch) {
|
||||
const id = parseInt(idMatch[1], 10);
|
||||
const title = (titleMatch ? titleMatch[1] : '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const body = (bodyMatch ? bodyMatch[1] : '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
||||
|
||||
let statusText = 'در انتظار تایید';
|
||||
if (status === 1) statusText = 'تایید شده';
|
||||
else if (status === 2 || status === -1) statusText = 'نیاز به ویرایش';
|
||||
|
||||
patterns.push({ id, title, body, status, statusText });
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all registered patterns from MeliPayamak (with local cache sync)
|
||||
*/
|
||||
async getPatterns(): Promise<MeliPayamakPattern[]> {
|
||||
const config = await this.getSmsConfig();
|
||||
|
||||
let remotePatterns: MeliPayamakPattern[] = [];
|
||||
if (config.username && config.password) {
|
||||
try {
|
||||
const rawXml = await this.postAsmx('GetSharedServiceBody', {
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
});
|
||||
remotePatterns = this.parsePatternsXml(rawXml);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[SMS Patterns] Remote fetch failed: ${err.message}. Using stored patterns.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load local stored patterns from settings table
|
||||
const storedSetting = await this.prisma.setting.findFirst({
|
||||
where: { category: 'sms_patterns' },
|
||||
});
|
||||
const localPatterns: MeliPayamakPattern[] = (storedSetting?.value as any) || [];
|
||||
|
||||
// Merge remote and local (remote takes precedence on ID match)
|
||||
const map = new Map<number, MeliPayamakPattern>();
|
||||
|
||||
// Add configured system patterns by default if map is empty
|
||||
const defaultConfigs: Array<{ id: number; title: string; body: string; assigned: string }> = [
|
||||
{ id: config.otpBodyId, title: 'کد تایید ورود و ثبتنام (OTP)', body: 'کد ورود به کانینا: {0}', assigned: 'otp' },
|
||||
{ id: config.orderBodyId, title: 'تایید و ثبت فاکتور سفارش', body: 'سفارش شما به شماره {0} با مبلغ {1} ثبت گردید.', assigned: 'order' },
|
||||
{ id: config.shippingBodyId, title: 'ارسال کد رهگیری پستی', body: 'مرسوله سفارش {0} با کد رهگیری پستی {1} ارسال شد.', assigned: 'shipping' },
|
||||
{ id: config.b2bBodyId, title: 'اطلاعرسانی درخواست B2B', body: 'همکار گرامی {0} درخواست شما دریافت شد.', assigned: 'b2b' },
|
||||
];
|
||||
|
||||
for (const d of defaultConfigs) {
|
||||
if (d.id > 0) {
|
||||
map.set(d.id, {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
body: d.body,
|
||||
status: 1,
|
||||
statusText: 'تایید شده',
|
||||
assignedTo: [d.assigned],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const lp of localPatterns) {
|
||||
if (lp.id) map.set(lp.id, { ...lp });
|
||||
}
|
||||
|
||||
for (const rp of remotePatterns) {
|
||||
const existing = map.get(rp.id);
|
||||
map.set(rp.id, {
|
||||
...rp,
|
||||
assignedTo: existing?.assignedTo || [],
|
||||
});
|
||||
}
|
||||
|
||||
// Mark current bindings
|
||||
const result = Array.from(map.values()).map((p) => {
|
||||
const assigned: string[] = [];
|
||||
if (p.id === config.otpBodyId) assigned.push('کد تایید OTP');
|
||||
if (p.id === config.orderBodyId) assigned.push('تایید سفارش');
|
||||
if (p.id === config.shippingBodyId) assigned.push('کد رهگیری پست');
|
||||
if (p.id === config.b2bBodyId) assigned.push('همکاران B2B');
|
||||
if (p.id === config.petCareBodyId) assigned.push('یادآور سلامت پت');
|
||||
return { ...p, assignedTo: assigned };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new pattern to MeliPayamak
|
||||
*/
|
||||
async addPattern(title: string, body: string, blackListId: number = 0): Promise<{ success: boolean; bodyId?: number; message: string }> {
|
||||
const config = await this.getSmsConfig();
|
||||
|
||||
if (!config.username || !config.password) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'مشخصات نام کاربری و رمز عبور سامانه پیامک تنظیم نشده است.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawXml = await this.postAsmx('SharedServiceBodyAdd', {
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
title,
|
||||
body,
|
||||
blackListId,
|
||||
});
|
||||
|
||||
const match = rawXml.match(/<int.*?>(-?\d+)<\/int>/i) || rawXml.match(/>(-?\d+)</);
|
||||
const code = match ? parseInt(match[1], 10) : 0;
|
||||
|
||||
if (code > 0) {
|
||||
// Store locally
|
||||
await this.saveLocalPattern({
|
||||
id: code,
|
||||
title,
|
||||
body,
|
||||
status: 0,
|
||||
statusText: 'در انتظار تایید',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
bodyId: code,
|
||||
message: `الگو با موفقیت ثبت شد و شناسه اختصاصی ${code} دریافت گردید. (وضعیت: در انتظار تایید ناظر)`,
|
||||
};
|
||||
} else if (code === 0) {
|
||||
return { success: false, message: 'نام کاربری یا کلمه عبور ملی پیامک اشتباه است.' };
|
||||
} else if (code === -2) {
|
||||
return { success: false, message: 'شناسه لیست سیاه ویژه اشتباه است.' };
|
||||
} else {
|
||||
return { success: false, message: `خطا در ثبت پترن با کد پاسخ: ${code}` };
|
||||
}
|
||||
} catch (err: any) {
|
||||
const generatedId = Math.floor(500000 + Math.random() * 90000);
|
||||
await this.saveLocalPattern({
|
||||
id: generatedId,
|
||||
title,
|
||||
body,
|
||||
status: 0,
|
||||
statusText: 'ثبت محلی (در انتظار اتصال سرور)',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
bodyId: generatedId,
|
||||
message: `الگو به صورت محلی با شناسه موقت ${generatedId} ذخیره شد (${err.message}).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing pattern in MeliPayamak
|
||||
*/
|
||||
async editPattern(bodyId: number, body: string): Promise<{ success: boolean; message: string }> {
|
||||
const config = await this.getSmsConfig();
|
||||
|
||||
if (!config.username || !config.password) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'مشخصات نام کاربری و رمز عبور سامانه پیامک تنظیم نشده است.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawXml = await this.postAsmx('SharedServiceBodyEdit', {
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
bodyId,
|
||||
body,
|
||||
});
|
||||
|
||||
const match = rawXml.match(/<int.*?>(-?\d+)<\/int>/i) || rawXml.match(/>(-?\d+)</);
|
||||
const code = match ? parseInt(match[1], 10) : 0;
|
||||
|
||||
if (code === 1) {
|
||||
await this.updateLocalPattern(bodyId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: 'ویرایش الگو با موفقیت انجام شد و منتظر تایید مجدد ناظر میباشد.',
|
||||
};
|
||||
} else if (code === -1) {
|
||||
await this.updateLocalPattern(bodyId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: 'متن الگو در سیستم بروز شد (توجه: فقط الگوهای در وضعیت نیاز به ویرایش در وبسرویس اصلاح میشوند).',
|
||||
};
|
||||
} else {
|
||||
return { success: false, message: `خطا در ویرایش الگو با کد پاسخ: ${code}` };
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this.updateLocalPattern(bodyId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: `متن الگو در دیتابیس داخلی بروز شد (${err.message}).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async saveLocalPattern(pattern: MeliPayamakPattern) {
|
||||
try {
|
||||
const setting = await this.prisma.setting.findFirst({
|
||||
where: { category: 'sms_patterns' },
|
||||
});
|
||||
const list: MeliPayamakPattern[] = (setting?.value as any) || [];
|
||||
const updated = [pattern, ...list.filter((p) => p.id !== pattern.id)];
|
||||
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: 'sms_patterns_config' },
|
||||
update: { category: 'sms_patterns', value: updated as any },
|
||||
create: { key: 'sms_patterns_config', category: 'sms_patterns', value: updated as any },
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Failed to save local pattern: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateLocalPattern(bodyId: number, newBody: string) {
|
||||
try {
|
||||
const setting = await this.prisma.setting.findFirst({
|
||||
where: { category: 'sms_patterns' },
|
||||
});
|
||||
const list: MeliPayamakPattern[] = (setting?.value as any) || [];
|
||||
const updated = list.map((p) => (p.id === bodyId ? { ...p, body: newBody, status: 0, statusText: 'در انتظار تایید' } : p));
|
||||
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: 'sms_patterns_config' },
|
||||
update: { category: 'sms_patterns', value: updated as any },
|
||||
create: { key: 'sms_patterns_config', category: 'sms_patterns', value: updated as any },
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Failed to update local pattern: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pattern SMS using MeliPayamak Shared Service Line (Bypasses Blacklist)
|
||||
*/
|
||||
|
||||
@ -158,5 +158,39 @@ export class SettingsController {
|
||||
) {
|
||||
return this.settingsService.testSms(phone, patternId, args);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Get('sms/patterns')
|
||||
@ApiOperation({ summary: 'دریافت لیست تمامی پترنهای تعریف شده در ملی پیامک' })
|
||||
getPatterns() {
|
||||
return this.settingsService.getPatterns();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Post('sms/patterns')
|
||||
@ApiOperation({ summary: 'درج الگوی جدید در سامانه ملی پیامک' })
|
||||
addPattern(
|
||||
@Body('title') title: string,
|
||||
@Body('body') body: string,
|
||||
@Body('blackListId') blackListId?: number,
|
||||
) {
|
||||
return this.settingsService.addPattern(title, body, blackListId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Put('sms/patterns/:bodyId')
|
||||
@ApiOperation({ summary: 'ویرایش الگوی رد شده یا در حال ویرایش در ملی پیامک' })
|
||||
editPattern(
|
||||
@Param('bodyId') bodyId: string,
|
||||
@Body('body') body: string,
|
||||
) {
|
||||
return this.settingsService.editPattern(Number(bodyId), body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -103,5 +103,17 @@ export class SettingsService {
|
||||
async testSms(targetPhone: string, patternId?: number, args?: string[]) {
|
||||
return this.smsService.testSms(targetPhone, patternId, args);
|
||||
}
|
||||
|
||||
async getPatterns() {
|
||||
return this.smsService.getPatterns();
|
||||
}
|
||||
|
||||
async addPattern(title: string, body: string, blackListId?: number) {
|
||||
return this.smsService.addPattern(title, body, blackListId);
|
||||
}
|
||||
|
||||
async editPattern(bodyId: number, body: string) {
|
||||
return this.smsService.editPattern(bodyId, body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,17 @@ import {
|
||||
EyeOff,
|
||||
Sparkles,
|
||||
Smartphone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Edit3,
|
||||
Copy,
|
||||
Check,
|
||||
Layers,
|
||||
SlidersHorizontal,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
X,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
@ -31,7 +42,18 @@ interface SmsConfigState {
|
||||
petCareBodyId: number | string;
|
||||
}
|
||||
|
||||
interface PatternItem {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
status: number; // 0 = Pending, 1 = Approved, 2 = Needs Edit
|
||||
statusText?: string;
|
||||
assignedTo?: string[];
|
||||
}
|
||||
|
||||
export default function SmsSettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'patterns'>('settings');
|
||||
|
||||
const [config, setConfig] = useState<SmsConfigState>({
|
||||
enabled: true,
|
||||
username: '',
|
||||
@ -44,9 +66,21 @@ export default function SmsSettingsPage() {
|
||||
petCareBodyId: '0',
|
||||
});
|
||||
|
||||
const [patterns, setPatterns] = useState<PatternItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingPatterns, setIsLoadingPatterns] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
// Modals
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [newPatternTitle, setNewPatternTitle] = useState('');
|
||||
const [newPatternBody, setNewPatternBody] = useState('');
|
||||
const [isSubmittingPattern, setIsSubmittingPattern] = useState(false);
|
||||
const [editingPattern, setEditingPattern] = useState<PatternItem | null>(null);
|
||||
const [editPatternBody, setEditPatternBody] = useState('');
|
||||
|
||||
// Test SMS State
|
||||
const [testPhone, setTestPhone] = useState('');
|
||||
@ -60,13 +94,16 @@ export default function SmsSettingsPage() {
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
api
|
||||
.get('/settings/sms')
|
||||
.then((res) => {
|
||||
if (!isSubscribed) return;
|
||||
const data = res.data?.data || res.data;
|
||||
const fetchSettingsAndPatterns = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [resConfig, resPatterns] = await Promise.allSettled([
|
||||
api.get('/settings/sms'),
|
||||
api.get('/settings/sms/patterns'),
|
||||
]);
|
||||
|
||||
if (resConfig.status === 'fulfilled') {
|
||||
const data = resConfig.value.data?.data || resConfig.value.data;
|
||||
if (data) {
|
||||
setConfig({
|
||||
enabled: data.enabled !== undefined ? Boolean(data.enabled) : true,
|
||||
@ -80,20 +117,38 @@ export default function SmsSettingsPage() {
|
||||
petCareBodyId: data.petCareBodyId || '0',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch SMS settings:', err);
|
||||
toast.error('خطا در دریافت تنظیمات پیامک');
|
||||
})
|
||||
.finally(() => {
|
||||
if (isSubscribed) setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
if (resPatterns.status === 'fulfilled') {
|
||||
const pList = resPatterns.value.data?.data || resPatterns.value.data || [];
|
||||
setPatterns(Array.isArray(pList) ? pList : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load SMS data:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettingsAndPatterns();
|
||||
}, []);
|
||||
|
||||
const refreshPatterns = async () => {
|
||||
try {
|
||||
setIsLoadingPatterns(true);
|
||||
const res = await api.get('/settings/sms/patterns');
|
||||
const pList = res.data?.data || res.data || [];
|
||||
setPatterns(Array.isArray(pList) ? pList : []);
|
||||
toast.success('لیست الگوها از سامانه ملی پیامک بروزرسانی شد');
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh patterns:', err);
|
||||
toast.error('خطا در دریافت لیست پترنها از ملی پیامک');
|
||||
} finally {
|
||||
setIsLoadingPatterns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@ -112,6 +167,7 @@ export default function SmsSettingsPage() {
|
||||
|
||||
await api.patch('/settings/sms', payload);
|
||||
toast.success('تنظیمات درگاه پیامک با موفقیت در دیتابیس ذخیره شد');
|
||||
refreshPatterns();
|
||||
} catch (err) {
|
||||
console.error('Failed to update SMS settings:', err);
|
||||
toast.error('خطا در ذخیرهسازی تنظیمات پیامک');
|
||||
@ -120,6 +176,88 @@ export default function SmsSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePattern = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPatternTitle || !newPatternBody) {
|
||||
toast.error('لطفاً عنوان و متن الگو را وارد نمایید');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingPattern(true);
|
||||
const res = await api.post('/settings/sms/patterns', {
|
||||
title: newPatternTitle.trim(),
|
||||
body: newPatternBody.trim(),
|
||||
});
|
||||
|
||||
const resData = res.data?.data || res.data;
|
||||
if (resData.success) {
|
||||
toast.success(resData.message || 'الگو با موفقیت ثبت شد');
|
||||
setIsAddModalOpen(false);
|
||||
setNewPatternTitle('');
|
||||
setNewPatternBody('');
|
||||
refreshPatterns();
|
||||
} else {
|
||||
toast.error(resData.message || 'خطا در ثبت الگو در سامانه ملی پیامک');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to add pattern:', err);
|
||||
toast.error(err.response?.data?.message || 'خطای سرور در ثبت الگو');
|
||||
} finally {
|
||||
setIsSubmittingPattern(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditPattern = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingPattern || !editPatternBody) return;
|
||||
|
||||
try {
|
||||
setIsSubmittingPattern(true);
|
||||
const res = await api.put(`/settings/sms/patterns/${editingPattern.id}`, {
|
||||
body: editPatternBody.trim(),
|
||||
});
|
||||
|
||||
const resData = res.data?.data || res.data;
|
||||
if (resData.success) {
|
||||
toast.success(resData.message || 'ویرایش الگو ثبت شد');
|
||||
setIsEditModalOpen(false);
|
||||
setEditingPattern(null);
|
||||
refreshPatterns();
|
||||
} else {
|
||||
toast.error(resData.message || 'خطا در ویرایش الگو');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to edit pattern:', err);
|
||||
toast.error(err.response?.data?.message || 'خطای سرور در ویرایش الگو');
|
||||
} finally {
|
||||
setIsSubmittingPattern(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignPattern = async (type: keyof SmsConfigState, patternId: number) => {
|
||||
const updated = { ...config, [type]: String(patternId) };
|
||||
setConfig(updated);
|
||||
|
||||
try {
|
||||
await api.patch('/settings/sms', {
|
||||
...updated,
|
||||
[type]: Number(patternId),
|
||||
});
|
||||
toast.success(`الگوی ${patternId} به عنوان رویداد مورد نظر تنظیم شد`);
|
||||
refreshPatterns();
|
||||
} catch {
|
||||
toast.error('خطا در ذخیره اتصال پترن');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (id: number) => {
|
||||
navigator.clipboard.writeText(String(id));
|
||||
setCopiedId(id);
|
||||
toast.success(`کد پترن ${id} کپی شد`);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleSendTestSms = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!testPhone) {
|
||||
@ -182,19 +320,52 @@ 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 Gateway)
|
||||
مدیریت درگاه و الگوهای پیامک (MeliPayamak)
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 ${
|
||||
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 ${
|
||||
activeTab === 'patterns'
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
{patterns.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-12">
|
||||
<Spinner size="lg" className="text-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
) : activeTab === 'settings' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Settings Form (2 cols on large) */}
|
||||
<form onSubmit={handleSave} className="lg:col-span-2 space-y-6">
|
||||
@ -294,11 +465,15 @@ export default function SmsSettingsPage() {
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<Hash className="w-5 h-5 text-indigo-600" />
|
||||
شناسههای قالب و الگوهای خدماتی (Pattern Body IDs)
|
||||
شناسههای قالب و الگوهای خدماتی فعال (Pattern Body IDs)
|
||||
</h3>
|
||||
<span className="text-[11px] bg-indigo-50 text-indigo-700 font-bold px-2.5 py-1 rounded-full flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> عبور از بلکلیست مخابرات
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('patterns')}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-bold flex items-center gap-1"
|
||||
>
|
||||
مشاهده و تعریف الگوها <ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@ -506,17 +681,333 @@ export default function SmsSettingsPage() {
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Documentation Help Card */}
|
||||
<div className="bg-slate-50 p-5 rounded-2xl border border-slate-200 space-y-3">
|
||||
<h4 className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<HelpCircle className="w-4 h-4 text-purple-600" />
|
||||
راهنمای پترنهای ملی پیامک
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-600 leading-relaxed">
|
||||
متن پترنها در پنل ملی پیامک باید از بخش «ارسال بر اساس پترن» ثبت شده و به تایید ناظر برسد. متغیرها به صورت <code className="bg-white px-1 py-0.5 rounded text-purple-700 font-mono font-bold">{'{0}'}</code> و <code className="bg-white px-1 py-0.5 rounded text-purple-700 font-mono font-bold">{'{1}'}</code> تعریف میشوند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Patterns Management Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Action Bar */}
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-gray-700">
|
||||
مجموع الگوهای ثبت شده: <span className="text-purple-600 font-mono">{patterns.length}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshPatterns}
|
||||
disabled={isLoadingPatterns}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-xl transition-all border border-gray-200 flex items-center gap-1.5 text-xs font-bold"
|
||||
title="بازخوانی از وبسرویس ملی پیامک"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingPatterns ? 'animate-spin text-purple-600' : ''}`} />
|
||||
<span>بروزرسانی از ملی پیامک</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white text-xs font-bold py-2.5 px-5 rounded-xl transition-all flex items-center gap-2 shadow-md shadow-purple-200"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
درج الگوی جدید (Add Pattern)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Patterns 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">شناسه الگو (BodyId)</th>
|
||||
<th className="p-4">عنوان الگو</th>
|
||||
<th className="p-4">متن الگو و متغیرها</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">
|
||||
{patterns.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-8 text-center text-gray-400 text-xs">
|
||||
هیچ الگویی یافت نشد. میتوانید با دکمه «درج الگوی جدید» اولین پترن خود را ثبت کنید.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
patterns.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-purple-50/40 transition-colors">
|
||||
<td className="p-4 font-mono font-bold text-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-lg text-xs">
|
||||
{p.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(p.id)}
|
||||
className="text-gray-400 hover:text-purple-600 p-1"
|
||||
title="کپی شناسه"
|
||||
>
|
||||
{copiedId === p.id ? <Check className="w-3.5 h-3.5 text-emerald-600" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="p-4 font-bold text-gray-900">{p.title}</td>
|
||||
|
||||
<td className="p-4 text-xs text-gray-600 max-w-md">
|
||||
<div className="bg-slate-50 p-2.5 rounded-xl border border-slate-200/80 font-sans leading-relaxed">
|
||||
{p.body}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="p-4 text-xs">
|
||||
{p.status === 1 ? (
|
||||
<span className="inline-flex items-center gap-1.5 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>
|
||||
) : p.status === 2 || p.status === -1 ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-bold bg-rose-50 text-rose-700 border border-rose-200">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
نیاز به ویرایش
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-bold bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
در انتظار تایید ناظر
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-4 text-xs">
|
||||
{p.assignedTo && p.assignedTo.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.assignedTo.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-purple-100 text-purple-800 text-[10px] font-bold px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 text-[11px]">آزاد (بدون اتصال)</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<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) {
|
||||
handleAssignPattern(e.target.value as keyof SmsConfigState, p.id);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
defaultValue=""
|
||||
className="text-[11px] font-bold bg-gray-50 border border-gray-200 rounded-lg p-1.5 text-gray-700 outline-none hover:border-purple-300"
|
||||
>
|
||||
<option value="" disabled>
|
||||
انتساب به رویداد...
|
||||
</option>
|
||||
<option value="otpBodyId">کد تایید OTP</option>
|
||||
<option value="orderBodyId">تایید ثبت سفارش</option>
|
||||
<option value="shippingBodyId">کد رهگیری پست</option>
|
||||
<option value="b2bBodyId">درخواست همکار B2B</option>
|
||||
<option value="petCareBodyId">یادآور پرونده سلامت پت</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingPattern(p);
|
||||
setEditPatternBody(p.body);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="ویرایش متن الگو"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Add New Pattern */}
|
||||
{isAddModalOpen && (
|
||||
<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-xl 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">
|
||||
<Plus className="w-5 h-5 text-purple-600" />
|
||||
درج الگوی جدید در سامانه ملی پیامک
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAddModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 p-1 rounded-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreatePattern} className="p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">عنوان الگو (Title) *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newPatternTitle}
|
||||
onChange={(e) => setNewPatternTitle(e.target.value)}
|
||||
placeholder="مثلاً: تایید سفارش کانینا"
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-bold text-gray-700">متن الگو (Body) *</label>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<span className="text-gray-400">درج سریع متغیر:</span>
|
||||
{['{0}', '{1}', '{2}'].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setNewPatternBody((prev) => `${prev} ${v}`)}
|
||||
className="bg-purple-50 text-purple-700 hover:bg-purple-100 font-mono font-bold px-1.5 py-0.5 rounded border border-purple-200"
|
||||
>
|
||||
+{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={4}
|
||||
required
|
||||
value={newPatternBody}
|
||||
onChange={(e) => setNewPatternBody(e.target.value)}
|
||||
placeholder="کاربر گرامی، سفارش {0} با مبلغ {1} تومان در کانینا ثبت شد."
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-medium leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview Box */}
|
||||
<div className="p-3.5 bg-slate-50 rounded-xl border border-slate-200 space-y-1">
|
||||
<span className="text-[11px] font-bold text-gray-500">پیشنمایش الگو:</span>
|
||||
<p className="text-xs text-gray-800 font-sans leading-relaxed">
|
||||
{newPatternBody || 'متن الگو پس از تایپ در اینجا نمایش داده خواهد شد...'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAddModalOpen(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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmittingPattern}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white font-bold px-6 py-2.5 rounded-xl text-xs flex items-center gap-2 shadow-md shadow-purple-200 disabled:opacity-50"
|
||||
>
|
||||
{isSubmittingPattern ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
|
||||
ارسال و ثبت الگو در ملی پیامک
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Edit Pattern */}
|
||||
{isEditModalOpen && editingPattern && (
|
||||
<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-xl 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">
|
||||
<Edit3 className="w-5 h-5 text-blue-600" />
|
||||
ویرایش متن الگو (BodyId: {editingPattern.id})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 p-1 rounded-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEditPattern} className="p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">عنوان الگو</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={editingPattern.title}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 bg-gray-100 text-sm font-bold text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-bold text-gray-700">متن اصلاح شده الگو *</label>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<span className="text-gray-400">درج سریع:</span>
|
||||
{['{0}', '{1}', '{2}'].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setEditPatternBody((prev) => `${prev} ${v}`)}
|
||||
className="bg-purple-50 text-purple-700 hover:bg-purple-100 font-mono font-bold px-1.5 py-0.5 rounded border border-purple-200"
|
||||
>
|
||||
+{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={4}
|
||||
required
|
||||
value={editPatternBody}
|
||||
onChange={(e) => setEditPatternBody(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-blue-500 text-sm font-medium leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditModalOpen(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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmittingPattern}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold px-6 py-2.5 rounded-xl text-xs flex items-center gap-2 shadow-md shadow-blue-200 disabled:opacity-50"
|
||||
>
|
||||
{isSubmittingPattern ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
|
||||
ذخیره ویرایش الگو
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user