feat(admin,sms): implement full pattern management CRUD and live binding directly from admin panel

This commit is contained in:
parsa aghaei 2026-08-16 11:52:13 +03:30
parent 09fd339e89
commit 0934b4f3af
4 changed files with 868 additions and 35 deletions

View File

@ -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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const body = (bodyMatch ? bodyMatch[1] : '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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)
*/

View File

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

View File

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

View File

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