From 0934b4f3afabdfa0705262782147cfb2175dfff5 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Sun, 16 Aug 2026 11:52:13 +0330 Subject: [PATCH] feat(admin,sms): implement full pattern management CRUD and live binding directly from admin panel --- backend/src/common/services/sms.service.ts | 296 +++++++++ backend/src/settings/settings.controller.ts | 34 ++ backend/src/settings/settings.service.ts | 12 + .../admin-panel/src/pages/SmsSettingsPage.tsx | 561 ++++++++++++++++-- 4 files changed, 868 insertions(+), 35 deletions(-) diff --git a/backend/src/common/services/sms.service.ts b/backend/src/common/services/sms.service.ts index 6d2d0fd..bf3e135 100644 --- a/backend/src/common/services/sms.service.ts +++ b/backend/src/common/services/sms.service.ts @@ -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): Promise { + 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 = /([\s\S]*?)<\/SharedServiceBody>/gi; + let match: RegExpExecArray | null; + + while ((match = itemRegex.exec(xml)) !== null) { + const block = match[1]; + const idMatch = block.match(/(\d+)<\/Id>/i); + const titleMatch = block.match(/([\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) */ diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts index fe52ea1..a974508 100644 --- a/backend/src/settings/settings.controller.ts +++ b/backend/src/settings/settings.controller.ts @@ -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); + } } diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts index 45c4de9..7bfb6d4 100644 --- a/backend/src/settings/settings.service.ts +++ b/backend/src/settings/settings.service.ts @@ -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); + } } diff --git a/frontend/admin-panel/src/pages/SmsSettingsPage.tsx b/frontend/admin-panel/src/pages/SmsSettingsPage.tsx index fe6d73f..3bf03d8 100644 --- a/frontend/admin-panel/src/pages/SmsSettingsPage.tsx +++ b/frontend/admin-panel/src/pages/SmsSettingsPage.tsx @@ -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> )}