2760 lines
131 KiB
TypeScript
2760 lines
131 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
MessageSquare,
|
||
Save,
|
||
Send,
|
||
ShieldCheck,
|
||
KeyRound,
|
||
Coins,
|
||
User,
|
||
Hash,
|
||
HelpCircle,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
Eye,
|
||
EyeOff,
|
||
Sparkles,
|
||
Smartphone,
|
||
Plus,
|
||
RefreshCw,
|
||
Edit3,
|
||
Copy,
|
||
Check,
|
||
Layers,
|
||
SlidersHorizontal,
|
||
Clock,
|
||
AlertTriangle,
|
||
X,
|
||
ArrowUpRight,
|
||
History,
|
||
Search,
|
||
Trash2,
|
||
TrendingUp,
|
||
RotateCcw,
|
||
Info,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Filter,
|
||
Zap,
|
||
CheckSquare,
|
||
Square,
|
||
Users,
|
||
Play,
|
||
FileText,
|
||
} from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import api from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Button from '../components/ui/Button';
|
||
import Modal from '../components/ui/Modal';
|
||
|
||
export interface SmsRule {
|
||
id: string;
|
||
title: string;
|
||
event: string;
|
||
enabled: boolean;
|
||
recipientType: 'customer' | 'admin' | 'custom';
|
||
customPhone?: string;
|
||
patternId: number;
|
||
variables: string[];
|
||
description?: string;
|
||
}
|
||
|
||
export interface SmsEventVariable {
|
||
key: string;
|
||
label: string;
|
||
sample: string;
|
||
}
|
||
|
||
export interface SmsEventDefinition {
|
||
event: string;
|
||
label: string;
|
||
description: string;
|
||
variables: SmsEventVariable[];
|
||
}
|
||
|
||
interface SmsConfigState {
|
||
enabled: boolean;
|
||
username: string;
|
||
password: string;
|
||
fromNumber: string;
|
||
adminPhone: string;
|
||
otpBodyId: number | string;
|
||
orderBodyId: number | string;
|
||
shippingBodyId: number | string;
|
||
b2bBodyId: number | string;
|
||
petCareBodyId: number | string;
|
||
rules: SmsRule[];
|
||
}
|
||
|
||
interface PatternItem {
|
||
id: number;
|
||
title: string;
|
||
body: string;
|
||
status: number; // 0 = Pending, 1 = Approved, 2 = Needs Edit
|
||
statusText?: string;
|
||
assignedTo?: string[];
|
||
}
|
||
|
||
interface SmsLogItem {
|
||
id: string;
|
||
receptor: string;
|
||
type: string;
|
||
patternId?: number | null;
|
||
args: string[];
|
||
messageText?: string | null;
|
||
status: 'SUCCESS' | 'FAILED' | 'DISABLED';
|
||
recId?: string | null;
|
||
errorMessage?: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface SmsLogStats {
|
||
total: number;
|
||
success: number;
|
||
failed: number;
|
||
successRate: number;
|
||
}
|
||
|
||
const toPersianDigits = (n: string | number | null | undefined): string => {
|
||
if (n === null || n === undefined) return '';
|
||
const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||
return n.toString().replace(/\d/g, (x) => farsiDigits[parseInt(x, 10)]);
|
||
};
|
||
|
||
export default function SmsSettingsPage() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const urlTab = searchParams.get('tab');
|
||
const validTabs: ('settings' | 'patterns' | 'triggers' | 'logs')[] = ['settings', 'patterns', 'triggers', 'logs'];
|
||
const activeTab: 'settings' | 'patterns' | 'triggers' | 'logs' =
|
||
urlTab && validTabs.includes(urlTab as any) ? (urlTab as any) : 'settings';
|
||
|
||
const setActiveTab = (tab: 'settings' | 'patterns' | 'triggers' | 'logs') => {
|
||
setSearchParams((prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
next.set('tab', tab);
|
||
return next;
|
||
}, { replace: false });
|
||
};
|
||
|
||
// Config State
|
||
const [config, setConfig] = useState<SmsConfigState>({
|
||
enabled: true,
|
||
username: '',
|
||
password: '',
|
||
fromNumber: '',
|
||
adminPhone: '',
|
||
otpBodyId: '508079',
|
||
orderBodyId: '508081',
|
||
shippingBodyId: '508082',
|
||
b2bBodyId: '508083',
|
||
petCareBodyId: '0',
|
||
rules: [],
|
||
});
|
||
|
||
const [patterns, setPatterns] = useState<PatternItem[]>([]);
|
||
const [eventDefinitions, setEventDefinitions] = useState<SmsEventDefinition[]>([]);
|
||
const [credit, setCredit] = useState<number | null>(null);
|
||
const [isLoadingCredit, setIsLoadingCredit] = useState(false);
|
||
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);
|
||
|
||
// Trigger Rules Modal State
|
||
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
|
||
const [editingRule, setEditingRule] = useState<SmsRule | null>(null);
|
||
const [ruleFormTitle, setRuleFormTitle] = useState('');
|
||
const [ruleFormEvent, setRuleFormEvent] = useState('ORDER_PAID');
|
||
const [ruleFormEnabled, setRuleFormEnabled] = useState(true);
|
||
const [ruleFormRecipientType, setRuleFormRecipientType] = useState<'customer' | 'admin' | 'custom'>('customer');
|
||
const [ruleFormCustomPhone, setRuleFormCustomPhone] = useState('');
|
||
const [ruleFormPatternId, setRuleFormPatternId] = useState('');
|
||
const [ruleFormVariables, setRuleFormVariables] = useState<string[]>([]);
|
||
const [ruleFormDescription, setRuleFormDescription] = useState('');
|
||
const [isTestingRuleId, setIsTestingRuleId] = useState<string | null>(null);
|
||
|
||
// User Picker for Custom Recipient
|
||
const [systemUsers, setSystemUsers] = useState<Array<{ id: string; firstName?: string; lastName?: string; mobile?: string; role?: string }>>([]);
|
||
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
||
const [userSearchQuery, setUserSearchQuery] = useState('');
|
||
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||
|
||
// Pattern 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('');
|
||
const [patternFilter, setPatternFilter] = useState('');
|
||
const [patternStatusFilter, setPatternStatusFilter] = useState<'ALL' | 'APPROVED' | 'PENDING' | 'REJECTED'>('ALL');
|
||
|
||
// Logs State
|
||
const [logs, setLogs] = useState<SmsLogItem[]>([]);
|
||
const [logStats, setLogStats] = useState<SmsLogStats>({
|
||
total: 0,
|
||
success: 0,
|
||
failed: 0,
|
||
successRate: 100,
|
||
});
|
||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||
const [logPage, setLogPage] = useState(1);
|
||
const [logTotalPages, setLogTotalPages] = useState(1);
|
||
const [logSearch, setLogSearch] = useState('');
|
||
const [logTypeFilter, setLogTypeFilter] = useState('ALL');
|
||
const [logStatusFilter, setLogStatusFilter] = useState('ALL');
|
||
const [logSortOrder, setLogSortOrder] = useState<'desc' | 'asc'>('desc');
|
||
const [selectedLog, setSelectedLog] = useState<SmsLogItem | null>(null);
|
||
const [isLogDetailModalOpen, setIsLogDetailModalOpen] = useState(false);
|
||
|
||
// Test SMS State
|
||
const [testPhone, setTestPhone] = useState('');
|
||
const [testPatternType, setTestPatternType] = useState('otp');
|
||
const [customPatternId, setCustomPatternId] = useState('');
|
||
const [testArg1, setTestArg1] = useState('123456');
|
||
const [testArg2, setTestArg2] = useState('150000');
|
||
const [isSendingTest, setIsSendingTest] = useState(false);
|
||
const [testResult, setTestResult] = useState<{
|
||
success: boolean;
|
||
message: string;
|
||
} | null>(null);
|
||
|
||
const fetchCredit = async () => {
|
||
try {
|
||
setIsLoadingCredit(true);
|
||
const res = await api.get('/settings/sms/credit');
|
||
const data = res.data?.data || res.data;
|
||
if (data && data.success) {
|
||
setCredit(Number(data.credit) || 0);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load SMS credit:', err);
|
||
} finally {
|
||
setIsLoadingCredit(false);
|
||
}
|
||
};
|
||
|
||
const fetchSettingsAndPatterns = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const [resConfig, resPatterns, resCredit, resEvents] = await Promise.allSettled([
|
||
api.get('/settings/sms'),
|
||
api.get('/settings/sms/patterns'),
|
||
api.get('/settings/sms/credit'),
|
||
api.get('/settings/sms/events'),
|
||
]);
|
||
|
||
if (resConfig.status === 'fulfilled') {
|
||
const data = resConfig.value.data?.data || resConfig.value.data;
|
||
if (data) {
|
||
setConfig({
|
||
enabled: data.enabled !== undefined ? Boolean(data.enabled) : true,
|
||
username: data.username || '',
|
||
password: data.password || '',
|
||
fromNumber: data.fromNumber || '',
|
||
adminPhone: data.adminPhone || '',
|
||
otpBodyId: data.otpBodyId || '508079',
|
||
orderBodyId: data.orderBodyId || '508081',
|
||
shippingBodyId: data.shippingBodyId || '508082',
|
||
b2bBodyId: data.b2bBodyId || '508083',
|
||
petCareBodyId: data.petCareBodyId || '0',
|
||
rules: Array.isArray(data.rules) ? data.rules : [],
|
||
});
|
||
}
|
||
}
|
||
|
||
if (resPatterns.status === 'fulfilled') {
|
||
const pList = resPatterns.value.data?.data || resPatterns.value.data || [];
|
||
setPatterns(Array.isArray(pList) ? pList : []);
|
||
}
|
||
|
||
if (resCredit.status === 'fulfilled') {
|
||
const cData = resCredit.value.data?.data || resCredit.value.data;
|
||
if (cData && cData.success) {
|
||
setCredit(Number(cData.credit) || 0);
|
||
}
|
||
}
|
||
|
||
if (resEvents.status === 'fulfilled') {
|
||
const eData = resEvents.value.data?.data || resEvents.value.data || [];
|
||
setEventDefinitions(Array.isArray(eData) ? eData : []);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load SMS data:', err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchLogs = useCallback(async () => {
|
||
try {
|
||
setIsLoadingLogs(true);
|
||
const params: Record<string, any> = {
|
||
page: logPage,
|
||
limit: 15,
|
||
sortOrder: logSortOrder,
|
||
};
|
||
|
||
if (logSearch) params.search = logSearch;
|
||
if (logTypeFilter !== 'ALL') params.type = logTypeFilter;
|
||
if (logStatusFilter !== 'ALL') params.status = logStatusFilter;
|
||
|
||
const res = await api.get('/settings/sms/logs', { params });
|
||
const data = res.data?.data || res.data;
|
||
|
||
if (data) {
|
||
setLogs(data.logs || []);
|
||
setLogTotalPages(data.totalPages || 1);
|
||
if (data.stats) {
|
||
setLogStats(data.stats);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch SMS logs:', err);
|
||
toast.error('خطا در دریافت لاگهای پیامک');
|
||
} finally {
|
||
setIsLoadingLogs(false);
|
||
}
|
||
}, [logPage, logSearch, logTypeFilter, logStatusFilter, logSortOrder]);
|
||
|
||
useEffect(() => {
|
||
fetchSettingsAndPatterns();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'logs') {
|
||
fetchLogs();
|
||
}
|
||
}, [activeTab, fetchLogs]);
|
||
|
||
const refreshPatterns = async () => {
|
||
try {
|
||
setIsLoadingPatterns(true);
|
||
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 {
|
||
setIsSaving(true);
|
||
const payload = {
|
||
enabled: Boolean(config.enabled),
|
||
username: config.username.trim(),
|
||
password: config.password.trim(),
|
||
fromNumber: config.fromNumber.trim(),
|
||
adminPhone: (config.adminPhone || '').trim(),
|
||
otpBodyId: Number(config.otpBodyId) || 0,
|
||
orderBodyId: Number(config.orderBodyId) || 0,
|
||
shippingBodyId: Number(config.shippingBodyId) || 0,
|
||
b2bBodyId: Number(config.b2bBodyId) || 0,
|
||
petCareBodyId: Number(config.petCareBodyId) || 0,
|
||
rules: config.rules || [],
|
||
};
|
||
|
||
await api.patch('/settings/sms', payload);
|
||
toast.success('تنظیمات درگاه پیامک با موفقیت در دیتابیس ذخیره شد');
|
||
refreshPatterns();
|
||
} catch (err) {
|
||
console.error('Failed to update SMS settings:', err);
|
||
toast.error('خطا در ذخیرهسازی تنظیمات پیامک');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
// Rule Handlers
|
||
const handleOpenAddRuleModal = (initialPatternId?: number, initialEvent?: string, initialTitle?: string) => {
|
||
setEditingRule(null);
|
||
setRuleFormTitle(initialTitle || '');
|
||
setRuleFormEvent(initialEvent || 'ORDER_PAID');
|
||
setRuleFormEnabled(true);
|
||
setRuleFormRecipientType('customer');
|
||
setRuleFormCustomPhone('');
|
||
setRuleFormPatternId(initialPatternId ? String(initialPatternId) : String(config.orderBodyId || ''));
|
||
setRuleFormVariables(['customerName', 'orderNumber', 'amount']);
|
||
setRuleFormDescription('');
|
||
setUserSearchQuery('');
|
||
setIsUserDropdownOpen(false);
|
||
setIsRuleModalOpen(true);
|
||
fetchSystemUsers();
|
||
};
|
||
|
||
const fetchSystemUsers = async (searchQuery = '') => {
|
||
try {
|
||
setIsLoadingUsers(true);
|
||
const res = await api.get(`/admin/users?limit=30${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}`);
|
||
const list = res.data?.data || [];
|
||
setSystemUsers(list);
|
||
} catch (err) {
|
||
console.error('Failed to fetch system users', err);
|
||
} finally {
|
||
setIsLoadingUsers(false);
|
||
}
|
||
};
|
||
|
||
const handleOpenEditRuleModal = (rule: SmsRule) => {
|
||
setEditingRule(rule);
|
||
setRuleFormTitle(rule.title);
|
||
setRuleFormEvent(rule.event);
|
||
setRuleFormEnabled(rule.enabled);
|
||
setRuleFormRecipientType(rule.recipientType);
|
||
setRuleFormCustomPhone(rule.customPhone || '');
|
||
setRuleFormPatternId(String(rule.patternId || ''));
|
||
setRuleFormVariables(rule.variables || []);
|
||
setRuleFormDescription(rule.description || '');
|
||
setUserSearchQuery('');
|
||
setIsUserDropdownOpen(false);
|
||
setIsRuleModalOpen(true);
|
||
fetchSystemUsers();
|
||
};
|
||
|
||
const handleDuplicateRule = (rule: SmsRule) => {
|
||
setEditingRule(null); // Create new rule on save
|
||
setRuleFormTitle(`${rule.title} (کپی)`);
|
||
setRuleFormEvent(rule.event);
|
||
setRuleFormEnabled(rule.enabled);
|
||
setRuleFormRecipientType(rule.recipientType);
|
||
setRuleFormCustomPhone(rule.customPhone || '');
|
||
setRuleFormPatternId(String(rule.patternId || ''));
|
||
setRuleFormVariables(rule.variables ? [...rule.variables] : []);
|
||
setRuleFormDescription(rule.description ? `${rule.description} (رونوشت)` : '');
|
||
setIsRuleModalOpen(true);
|
||
toast.info('اطلاعات سناریو تکثیر شد. پس از بازبینی، ذخیره نمایید.');
|
||
};
|
||
|
||
const handleSaveRule = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!ruleFormTitle.trim()) {
|
||
toast.error('لطفاً عنوان سناریو را وارد کنید');
|
||
return;
|
||
}
|
||
const pid = Number(ruleFormPatternId);
|
||
if (!pid || pid <= 0) {
|
||
toast.error('لطفاً شناسه الگو (Pattern ID) را مشخص کنید');
|
||
return;
|
||
}
|
||
if (ruleFormRecipientType === 'custom' && !ruleFormCustomPhone.trim()) {
|
||
toast.error('لطفاً شماره موبایل دلخواه گیرنده را وارد نمایید');
|
||
return;
|
||
}
|
||
|
||
const updatedRule: SmsRule = {
|
||
id: editingRule ? editingRule.id : `rule_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||
title: ruleFormTitle.trim(),
|
||
event: ruleFormEvent,
|
||
enabled: ruleFormEnabled,
|
||
recipientType: ruleFormRecipientType,
|
||
customPhone: ruleFormRecipientType === 'custom' ? ruleFormCustomPhone.trim() : undefined,
|
||
patternId: pid,
|
||
variables: ruleFormVariables,
|
||
description: ruleFormDescription.trim() || undefined,
|
||
};
|
||
|
||
const newRules: SmsRule[] = editingRule
|
||
? (config.rules || []).map((r) => (r.id === editingRule.id ? updatedRule : r))
|
||
: [...(config.rules || []), updatedRule];
|
||
|
||
const newConfig = { ...config, rules: newRules };
|
||
setConfig(newConfig);
|
||
setIsRuleModalOpen(false);
|
||
|
||
try {
|
||
await api.patch('/settings/sms', {
|
||
...newConfig,
|
||
otpBodyId: Number(newConfig.otpBodyId) || 0,
|
||
orderBodyId: Number(newConfig.orderBodyId) || 0,
|
||
shippingBodyId: Number(newConfig.shippingBodyId) || 0,
|
||
b2bBodyId: Number(newConfig.b2bBodyId) || 0,
|
||
petCareBodyId: Number(newConfig.petCareBodyId) || 0,
|
||
});
|
||
toast.success(editingRule ? 'سناریو با موفقیت ویرایش و ذخیره شد' : 'سناریوی جدید اضافه و ذخیره شد');
|
||
} catch (err) {
|
||
console.error('Failed to save rules to backend:', err);
|
||
toast.error('خطا در ذخیره سناریو در سرور');
|
||
}
|
||
};
|
||
|
||
const handleToggleRule = async (ruleId: string) => {
|
||
const newRules = (config.rules || []).map((r) => {
|
||
if (r.id === ruleId) {
|
||
return { ...r, enabled: !r.enabled };
|
||
}
|
||
return r;
|
||
});
|
||
|
||
const newConfig = { ...config, rules: newRules };
|
||
setConfig(newConfig);
|
||
|
||
try {
|
||
await api.patch('/settings/sms', {
|
||
...newConfig,
|
||
otpBodyId: Number(newConfig.otpBodyId) || 0,
|
||
orderBodyId: Number(newConfig.orderBodyId) || 0,
|
||
shippingBodyId: Number(newConfig.shippingBodyId) || 0,
|
||
b2bBodyId: Number(newConfig.b2bBodyId) || 0,
|
||
petCareBodyId: Number(newConfig.petCareBodyId) || 0,
|
||
});
|
||
toast.success('وضعیت سناریو بروزرسانی شد');
|
||
} catch (err) {
|
||
console.error('Failed to toggle rule:', err);
|
||
toast.error('خطا در بهروزرسانی وضعیت سناریو');
|
||
}
|
||
};
|
||
|
||
const handleDeleteRule = async (ruleId: string) => {
|
||
if (!window.confirm('آیا از حذف این سناریوی پیامکی اطمینان دارید؟')) return;
|
||
|
||
const newRules = (config.rules || []).filter((r) => r.id !== ruleId);
|
||
const newConfig = { ...config, rules: newRules };
|
||
setConfig(newConfig);
|
||
|
||
try {
|
||
await api.patch('/settings/sms', {
|
||
...newConfig,
|
||
otpBodyId: Number(newConfig.otpBodyId) || 0,
|
||
orderBodyId: Number(newConfig.orderBodyId) || 0,
|
||
shippingBodyId: Number(newConfig.shippingBodyId) || 0,
|
||
b2bBodyId: Number(newConfig.b2bBodyId) || 0,
|
||
petCareBodyId: Number(newConfig.petCareBodyId) || 0,
|
||
});
|
||
toast.success('سناریوی پیامک با موفقیت حذف شد');
|
||
} catch (err) {
|
||
console.error('Failed to delete rule:', err);
|
||
toast.error('خطا در حذف سناریو');
|
||
}
|
||
};
|
||
|
||
const handleTestRule = async (rule: SmsRule) => {
|
||
try {
|
||
setIsTestingRuleId(rule.id);
|
||
const res = await api.post('/settings/sms/test-rule', {
|
||
rule,
|
||
testPhone: testPhone ? testPhone.trim() : undefined,
|
||
});
|
||
const data = res.data?.data || res.data;
|
||
if (data && data.success) {
|
||
toast.success(`تست سناریو ارسال شد به: ${data.targetPhone || ''}`);
|
||
} else {
|
||
toast.error(data?.message || 'خطا در ارسال تست سناریو');
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Failed to test rule:', err);
|
||
toast.error(err.response?.data?.message || 'خطای شبکه در تست سناریو');
|
||
} finally {
|
||
setIsTestingRuleId(null);
|
||
}
|
||
};
|
||
|
||
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 handleDeleteLog = async (id: string) => {
|
||
if (!window.confirm('آیا از حذف این لاگ پیامک اطمینان دارید؟')) return;
|
||
try {
|
||
await api.delete(`/settings/sms/logs/${id}`);
|
||
toast.success('لاگ با موفقیت حذف شد');
|
||
fetchLogs();
|
||
} catch {
|
||
toast.error('خطا در حذف لاگ');
|
||
}
|
||
};
|
||
|
||
const handleClearAllLogs = async () => {
|
||
if (!window.confirm('آیا از پاکسازی تمامی لاگهای پیامک اطمینان دارید؟ این عمل غیرقابل بازگشت است.')) return;
|
||
try {
|
||
await api.delete('/settings/sms/logs');
|
||
toast.success('تمام لاگهای پیامک پاکسازی شدند');
|
||
fetchLogs();
|
||
} catch {
|
||
toast.error('خطا در پاکسازی لاگها');
|
||
}
|
||
};
|
||
|
||
const copyToClipboard = (text: string | number, label: string = 'کد') => {
|
||
navigator.clipboard.writeText(String(text));
|
||
toast.success(`${label} کپی شد`);
|
||
};
|
||
|
||
const handleSendTestSms = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!testPhone) {
|
||
toast.error('لطفاً شماره موبایل مقصد برای تست را وارد کنید');
|
||
return;
|
||
}
|
||
|
||
let patternId = Number(config.otpBodyId);
|
||
let args = [testArg1];
|
||
|
||
if (testPatternType === 'order') {
|
||
patternId = Number(config.orderBodyId);
|
||
args = ['ORD-9988', testArg2 || '150000'];
|
||
} else if (testPatternType === 'shipping') {
|
||
patternId = Number(config.shippingBodyId);
|
||
args = ['ORD-9988', 'POST-123456789'];
|
||
} else if (testPatternType === 'custom') {
|
||
patternId = Number(customPatternId);
|
||
args = [testArg1, testArg2].filter(Boolean);
|
||
}
|
||
|
||
try {
|
||
setIsSendingTest(true);
|
||
setTestResult(null);
|
||
|
||
const response = await api.post('/settings/sms/test', {
|
||
phone: testPhone.trim(),
|
||
patternId,
|
||
args,
|
||
});
|
||
|
||
const resData = response.data?.data || response.data;
|
||
setTestResult({
|
||
success: resData.success,
|
||
message: resData.message || (resData.success ? 'ارسال موفق بود' : 'خطا در ارسال'),
|
||
});
|
||
|
||
if (resData.success) {
|
||
toast.success('پیامک آزمایشی با موفقیت به شماره مقصد ارسال شد');
|
||
} else {
|
||
toast.error(resData.message || 'خطا در ارسال پیامک آزمایشی');
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Failed to dispatch test SMS:', err);
|
||
const errMsg = err.response?.data?.message || 'خطای شبکه یا سرور در ارسال پیامک آزمایشی';
|
||
setTestResult({
|
||
success: false,
|
||
message: errMsg,
|
||
});
|
||
toast.error(errMsg);
|
||
} finally {
|
||
setIsSendingTest(false);
|
||
}
|
||
};
|
||
|
||
const getTypeName = (type: string) => {
|
||
switch (type) {
|
||
case 'OTP':
|
||
return { label: 'کد تایید OTP', bg: 'bg-purple-100 text-purple-800 border-purple-200' };
|
||
case 'ORDER_CONFIRMATION':
|
||
return { label: 'ثبت و تایید سفارش', bg: 'bg-emerald-100 text-emerald-800 border-emerald-200' };
|
||
case 'SHIPPING_TRACKING':
|
||
return { label: 'کد رهگیری پست', bg: 'bg-blue-100 text-blue-800 border-blue-200' };
|
||
case 'B2B_NOTIFICATION':
|
||
return { label: 'همکاران B2B', bg: 'bg-amber-100 text-amber-800 border-amber-200' };
|
||
case 'PET_CARE_REMINDER':
|
||
return { label: 'یادآور سلامت پت', bg: 'bg-pink-100 text-pink-800 border-pink-200' };
|
||
case 'TEST':
|
||
return { label: 'پیامک تستی', bg: 'bg-slate-100 text-slate-800 border-slate-200' };
|
||
default:
|
||
return { label: type || 'متنی آزاد', bg: 'bg-gray-100 text-gray-800 border-gray-200' };
|
||
}
|
||
};
|
||
|
||
const formatDate = (iso: string) => {
|
||
try {
|
||
const date = new Date(iso);
|
||
return new Intl.DateTimeFormat('fa-IR', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
}).format(date);
|
||
} catch {
|
||
return iso;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Page Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<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 SMS Suite)
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">
|
||
پیکربندی اتصال وبسرویس، مدیریت الگوهای خدماتی و رهگیری لاگهای لحظهای ارسال
|
||
</p>
|
||
</div>
|
||
|
||
{/* Tab Toggle Navigation */}
|
||
<div className="flex items-center bg-gray-100 p-1 rounded-2xl border border-gray-200 flex-wrap">
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('settings')}
|
||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||
activeTab === 'settings'
|
||
? 'bg-white text-purple-700 shadow-sm'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
<SlidersHorizontal className="w-4 h-4" />
|
||
پیکربندی حساب
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('patterns')}
|
||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||
activeTab === 'patterns'
|
||
? 'bg-white text-purple-700 shadow-sm'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
<Layers className="w-4 h-4" />
|
||
مدیریت الگوها (Patterns)
|
||
{patterns.length > 0 && (
|
||
<span className="bg-purple-100 text-purple-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
|
||
{patterns.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('triggers')}
|
||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||
activeTab === 'triggers'
|
||
? 'bg-white text-purple-700 shadow-sm'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
<Zap className="w-4 h-4 text-amber-500" />
|
||
سناریوهای پیامک (Triggers)
|
||
{config.rules && config.rules.length > 0 && (
|
||
<span className="bg-amber-100 text-amber-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
|
||
{config.rules.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('logs')}
|
||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||
activeTab === 'logs'
|
||
? 'bg-white text-purple-700 shadow-sm'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
<History className="w-4 h-4" />
|
||
لاگها و رهگیری ارسال
|
||
{logStats.total > 0 && (
|
||
<span className="bg-purple-100 text-purple-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
|
||
{logStats.total}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{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">
|
||
{/* Status & Credentials Card */}
|
||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
|
||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-4 flex items-center gap-2">
|
||
<ShieldCheck className="w-5 h-5 text-purple-600" />
|
||
وضعیت سامانه و مشخصات حساب کاربری
|
||
</h3>
|
||
|
||
{/* Master Enabled Switch */}
|
||
<div className="flex items-center justify-between p-4 bg-purple-50 rounded-xl border border-purple-100">
|
||
<div>
|
||
<h4 className="font-bold text-gray-900 text-sm">فعال بودن ارسال پیامک در سراسر سیستم</h4>
|
||
<p className="text-xs text-gray-500 mt-0.5">
|
||
در صورت غیرفعال بودن، پیامکهای ثبت سفارش و OTP به صورت واقعی ارسال نخواهند شد.
|
||
</p>
|
||
</div>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="sr-only peer"
|
||
checked={config.enabled}
|
||
onChange={(e) => setConfig({ ...config, enabled: e.target.checked })}
|
||
/>
|
||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Username & Password */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1 flex items-center gap-1.5">
|
||
<User className="w-3.5 h-3.5 text-gray-500" />
|
||
نام کاربری یا شماره پنل ملی پیامک (Username) *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={config.username}
|
||
onChange={(e) => setConfig({ ...config, username: e.target.value })}
|
||
placeholder="مثلاً: 09364100228"
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500 mt-1">شماره همراه یا نام کاربری ورود به پنل melipayamak.com</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1 flex items-center gap-1.5">
|
||
<KeyRound className="w-3.5 h-3.5 text-gray-500" />
|
||
کلمه عبور یا کلید API (Password / API Key) *
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type={showPassword ? 'text' : 'password'}
|
||
required
|
||
value={config.password}
|
||
onChange={(e) => setConfig({ ...config, password: e.target.value })}
|
||
placeholder="••••••••••••"
|
||
className="w-full border border-gray-200 rounded-xl p-3 pl-11 pr-4 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white text-left"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPassword(!showPassword)}
|
||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||
title={showPassword ? 'پنهان کردن رمز' : 'نمایش رمز'}
|
||
>
|
||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] text-gray-500 mt-1">رمز عبور اختصاصی پنل یا کلید وبسرویس</p>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="block text-xs font-bold text-gray-700 mb-1 flex items-center gap-1.5">
|
||
<Smartphone className="w-3.5 h-3.5 text-gray-500" />
|
||
شماره خط فرستنده پیامک (From Number - اختیاری)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={config.fromNumber}
|
||
onChange={(e) => setConfig({ ...config, fromNumber: e.target.value })}
|
||
placeholder="مثلاً: 50004... (در پترن اشتراکی نیازی نیست)"
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500 mt-1">
|
||
در ارسال پترن خدماتی، ملی پیامک به صورت خودکار از خط اشتراکی استفاده میکند و پر کردن این فیلد اختیاری است.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="block text-xs font-bold text-gray-700 mb-1 flex items-center gap-1.5">
|
||
<Users className="w-3.5 h-3.5 text-purple-600" />
|
||
شماره همراه مدیران جهت دریافت پیامکهای سیستم (Admin Phone)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={config.adminPhone}
|
||
onChange={(e) => setConfig({ ...config, adminPhone: e.target.value })}
|
||
placeholder="مثلاً: 09364100228 (برای چند شماره با ویرگول یا کاما جدا کنید)"
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500 mt-1">
|
||
سناریوهایی که گیرنده آنها «مدیر سایت» تنظیم شده است، به این شمارهها پیامک ارسال خواهند کرد.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pattern IDs Card */}
|
||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
|
||
<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)
|
||
</h3>
|
||
<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">
|
||
{/* OTP Pattern */}
|
||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-bold text-gray-900">کد پترن ورود / ثبتنام (OTP)</label>
|
||
<span className="text-[10px] text-gray-500 font-mono bg-white px-2 py-0.5 rounded border border-gray-200">
|
||
متغیر: {'{0}'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
value={config.otpBodyId}
|
||
onChange={(e) => setConfig({ ...config, otpBodyId: e.target.value })}
|
||
placeholder="508079"
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500">کد تایید اعتبار ورود و فراموشی رمز کاربران</p>
|
||
</div>
|
||
|
||
{/* Order Confirmation Pattern */}
|
||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-bold text-gray-900">کد پترن ثبت و تایید سفارش</label>
|
||
<span className="text-[10px] text-gray-500 font-mono bg-white px-2 py-0.5 rounded border border-gray-200">
|
||
متغیرها: {'{0};{1}'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
value={config.orderBodyId}
|
||
onChange={(e) => setConfig({ ...config, orderBodyId: e.target.value })}
|
||
placeholder="508081"
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500">ارسال شماره فاکتور و مبلغ به خریدار پس از پرداخت</p>
|
||
</div>
|
||
|
||
{/* Shipping Tracking Pattern */}
|
||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-bold text-gray-900">کد پترن رهگیری پستی و ارسال</label>
|
||
<span className="text-[10px] text-gray-500 font-mono bg-white px-2 py-0.5 rounded border border-gray-200">
|
||
متغیرها: {'{0};{1}'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
value={config.shippingBodyId}
|
||
onChange={(e) => setConfig({ ...config, shippingBodyId: e.target.value })}
|
||
placeholder="508082"
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500">ارسال بارکد ۲۴ رقمی پست به مشتری هنگام تحویل مرسوله</p>
|
||
</div>
|
||
|
||
{/* B2B Pattern */}
|
||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-bold text-gray-900">کد پترن درخواست همکاری B2B</label>
|
||
<span className="text-[10px] text-gray-500 font-mono bg-white px-2 py-0.5 rounded border border-gray-200">
|
||
متغیر: {'{0}'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
value={config.b2bBodyId}
|
||
onChange={(e) => setConfig({ ...config, b2bBodyId: e.target.value })}
|
||
placeholder="508083"
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500">اطلاعرسانی ثبت یا تایید حساب متقاضیان عمدهفروشی</p>
|
||
</div>
|
||
|
||
{/* Pet Care Reminder Pattern */}
|
||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-2 md:col-span-2">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-bold text-gray-900">کد پترن یادآور سلامت و واکسیناسیون پت</label>
|
||
<span className="text-[10px] text-gray-500 font-mono bg-white px-2 py-0.5 rounded border border-gray-200">
|
||
متغیرها: {'{0};{1}'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
value={config.petCareBodyId}
|
||
onChange={(e) => setConfig({ ...config, petCareBodyId: e.target.value })}
|
||
placeholder="0 (در صورت تعریف نکردن 0 بگذارید)"
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<p className="text-[11px] text-gray-500">یادآور نوبت واکسن، انگلزدایی یا مصرف مکمل پت برای سرپرست</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Submit Button */}
|
||
<div className="flex justify-end pt-2">
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
size="md"
|
||
startIcon={Save}
|
||
isLoading={isSaving}
|
||
>
|
||
ذخیره تنظیمات درگاه پیامک
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
|
||
|
||
{/* Balance & Test SMS Sidebar (1 col on large) */}
|
||
<div className="space-y-6">
|
||
{/* Realtime Credit / Balance Card */}
|
||
<div className="bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900 p-6 rounded-2xl shadow-xl border border-purple-800/40 text-white space-y-4 relative overflow-hidden">
|
||
<div className="absolute -left-6 -bottom-6 w-28 h-28 bg-purple-500/20 rounded-full blur-2xl pointer-events-none" />
|
||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||
<div className="flex items-center gap-2">
|
||
<Coins className="w-5 h-5 text-amber-400" />
|
||
<h3 className="text-base font-black">موجودی و شارژ پنل پیامک</h3>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={fetchCredit}
|
||
disabled={isLoadingCredit}
|
||
className="p-1.5 text-white/70 hover:text-white hover:bg-white/10 rounded-xl transition-all border border-white/10 cursor-pointer"
|
||
title="استعلام مجدد شارژ"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingCredit ? 'animate-spin text-amber-400' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-white/70 font-medium">اعتبار باقیمانده در درگاه ملی پیامک:</p>
|
||
<div className="space-y-1.5">
|
||
{isLoadingCredit ? (
|
||
<div className="flex items-center gap-2 py-1 text-amber-400 text-sm font-bold">
|
||
<Spinner size="sm" />
|
||
<span>در حال استعلام از وبسرویس...</span>
|
||
</div>
|
||
) : credit !== null ? (
|
||
<>
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="text-3xl font-black text-amber-400 font-mono tracking-tight" dir="ltr">
|
||
{toPersianDigits(Math.floor(credit).toLocaleString())}
|
||
</span>
|
||
<span className="text-xs font-bold text-white/90">پالس (تعداد پیامک قابل ارسال)</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[11px] text-amber-300/90 font-medium bg-amber-500/10 border border-amber-500/20 px-2.5 py-1.5 rounded-lg">
|
||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 1118 0z" />
|
||
</svg>
|
||
<span>
|
||
معادل تقریبی: <strong className="font-bold font-mono text-white" dir="ltr">{toPersianDigits(Math.round(credit * 292.7).toLocaleString())}</strong> تومان موجودی کیفپول پنل
|
||
</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<span className="text-sm font-bold text-rose-300">اطلاعات شارژ دریافت نشد</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-3 bg-white/5 rounded-xl border border-white/10 text-[11px] text-white/75 space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<span>وضعیت حساب:</span>
|
||
<span className="font-bold text-emerald-400">فعال و متصل به وبسرویس</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span>نام کاربری:</span>
|
||
<span className="font-mono text-white/90" dir="ltr">{config.username || 'تنظیم نشده'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<a
|
||
href="https://melipayamak.com"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="w-full bg-white/10 hover:bg-white/20 text-white font-bold py-2.5 px-4 rounded-xl text-xs flex items-center justify-center gap-1.5 transition-colors border border-white/15"
|
||
>
|
||
<span>شارژ و افزایش اعتبار در ملی پیامک</span>
|
||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||
</a>
|
||
</div>
|
||
|
||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-5">
|
||
<div className="flex items-center gap-2 border-b border-gray-100 pb-3">
|
||
<Send className="w-5 h-5 text-blue-600" />
|
||
<h3 className="text-base font-bold text-gray-900">تست زنده ارسال پیامک</h3>
|
||
</div>
|
||
|
||
<p className="text-xs text-gray-500 leading-relaxed">
|
||
برای اطمینان از صحت نام کاربری، رمز عبور و شناسههای پترن تایید شده در ملی پیامک، یک پیامک آزمایشی به شماره خود ارسال کنید.
|
||
</p>
|
||
|
||
<form onSubmit={handleSendTestSms} className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">شماره موبایل مقصد *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={testPhone}
|
||
onChange={(e) => setTestPhone(e.target.value)}
|
||
placeholder="0912..."
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-blue-500 text-sm font-mono font-bold"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">نوع پترن برای تست</label>
|
||
<select
|
||
value={testPatternType}
|
||
onChange={(e) => setTestPatternType(e.target.value)}
|
||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-blue-500 text-xs font-bold bg-gray-50"
|
||
>
|
||
<option value="otp">کد تایید OTP (BodyId: {config.otpBodyId || '508079'})</option>
|
||
<option value="order">تایید سفارش (BodyId: {config.orderBodyId || '508081'})</option>
|
||
<option value="shipping">رهگیری پستی (BodyId: {config.shippingBodyId || '508082'})</option>
|
||
<option value="custom">پترن سفارشی با کد دلخواه</option>
|
||
</select>
|
||
</div>
|
||
|
||
{testPatternType === 'custom' && (
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">شناسه پترن دلخواه (BodyId)</label>
|
||
<input
|
||
type="number"
|
||
required
|
||
value={customPatternId}
|
||
onChange={(e) => setCustomPatternId(e.target.value)}
|
||
placeholder="مثلاً: 508079"
|
||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-blue-500 text-xs font-mono font-bold"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">متغیر تستی ۱ (کد یا متن)</label>
|
||
<input
|
||
type="text"
|
||
value={testArg1}
|
||
onChange={(e) => setTestArg1(e.target.value)}
|
||
placeholder="123456"
|
||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-blue-500 text-xs font-mono font-bold"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
{testResult && (
|
||
<div
|
||
className={`p-3 rounded-xl border text-xs flex items-start gap-2 ${
|
||
testResult.success
|
||
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
||
: 'bg-rose-50 border-rose-200 text-rose-800'
|
||
}`}
|
||
>
|
||
{testResult.success ? (
|
||
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-600 mt-0.5" />
|
||
) : (
|
||
<AlertCircle className="w-4 h-4 shrink-0 text-rose-600 mt-0.5" />
|
||
)}
|
||
<span className="font-medium leading-tight">{testResult.message}</span>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isSendingTest}
|
||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-xl transition-all flex items-center justify-center gap-2 shadow-md shadow-blue-200 disabled:opacity-50 text-xs"
|
||
>
|
||
{isSendingTest ? <Spinner size="sm" /> : <Send className="w-4 h-4" />}
|
||
ارسال پیامک تستی
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'triggers' ? (
|
||
/* Triggers / Scenarios Tab */
|
||
<div className="space-y-6">
|
||
{/* Top Actions & Overview */}
|
||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-200 flex flex-col md:flex-row items-center justify-between gap-4">
|
||
<div>
|
||
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
|
||
<Zap className="w-5 h-5 text-amber-500" />
|
||
سناریوها و قوانین ارسال پیامک خودکار (SMS Automation Rules)
|
||
</h3>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
برای هر رویداد (مانند ثبت یا پرداخت سفارش) میتوانید بینهایت پیامک همزمان به مشتری، مدیران یا شمارههای دلخواه (نظیر انبار و شرکت پست) با پترنهای اختصاصی تعریف کنید.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenAddRuleModal}
|
||
className="bg-amber-600 hover:bg-amber-700 text-white text-xs font-bold py-3 px-5 rounded-xl transition-all flex items-center gap-2 shadow-md shadow-amber-200 shrink-0"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
تعریف سناریوی جدید (Add Rule)
|
||
</button>
|
||
</div>
|
||
|
||
{/* Rules List / Grid */}
|
||
{(!config.rules || config.rules.length === 0) ? (
|
||
<div className="bg-white p-12 rounded-2xl border border-gray-200 text-center space-y-4">
|
||
<div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center mx-auto">
|
||
<Zap className="w-8 h-8" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<h4 className="font-bold text-gray-800 text-sm">هیچ سناریوی پیامکی فعالی وجود ندارد</h4>
|
||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||
سیستم در این حالت از تنظیمات پیشفرض پترنها در تب پیکربندی استفاده میکند. با کلیک بر روی دکمه زیر میتوانید سناریوهای چندمقصدی بسازید.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenAddRuleModal}
|
||
className="bg-amber-600 hover:bg-amber-700 text-white text-xs font-bold py-2.5 px-5 rounded-xl transition-all inline-flex items-center gap-2 shadow-sm"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
تعریف اولین سناریوی پیامکی
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
{config.rules.map((rule) => {
|
||
const eventDef = eventDefinitions.find((e) => e.event === rule.event);
|
||
const matchedPattern = patterns.find((p) => Number(p.id) === Number(rule.patternId));
|
||
|
||
return (
|
||
<div
|
||
key={rule.id}
|
||
className={`bg-white rounded-2xl border transition-all shadow-sm flex flex-col justify-between overflow-hidden ${
|
||
rule.enabled ? 'border-gray-200 hover:border-amber-400' : 'border-gray-200 opacity-60 bg-gray-50/50'
|
||
}`}
|
||
>
|
||
<div className="p-5 space-y-4">
|
||
{/* Header: Title, Active Switch, Badges */}
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-1 flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span
|
||
className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${
|
||
rule.recipientType === 'customer'
|
||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||
: rule.recipientType === 'admin'
|
||
? 'bg-purple-50 text-purple-700 border-purple-200'
|
||
: 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||
}`}
|
||
>
|
||
{rule.recipientType === 'customer'
|
||
? 'خریدار / مشتری'
|
||
: rule.recipientType === 'admin'
|
||
? 'مدیران سایت'
|
||
: `شماره دلخواه: ${rule.customPhone || ''}`}
|
||
</span>
|
||
|
||
<span className="text-[10px] bg-slate-100 text-slate-700 font-bold px-2 py-0.5 rounded-full border border-slate-200">
|
||
{eventDef ? eventDef.label : rule.event}
|
||
</span>
|
||
</div>
|
||
|
||
<h4 className="font-bold text-gray-900 text-sm truncate pt-1">{rule.title}</h4>
|
||
{rule.description && (
|
||
<p className="text-xs text-gray-500 leading-relaxed line-clamp-2">
|
||
{rule.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Toggle Button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => handleToggleRule(rule.id)}
|
||
className={`p-1.5 rounded-xl border transition-colors shrink-0 ${
|
||
rule.enabled
|
||
? 'bg-emerald-50 border-emerald-200 text-emerald-600 hover:bg-emerald-100'
|
||
: 'bg-gray-100 border-gray-300 text-gray-400 hover:bg-gray-200'
|
||
}`}
|
||
title={rule.enabled ? 'سناریو فعال است (کلیک برای غیرفعال کردن)' : 'سناریو غیرفعال است (کلیک برای فعالسازی)'}
|
||
>
|
||
{rule.enabled ? <CheckSquare className="w-5 h-5" /> : <Square className="w-5 h-5" />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Pattern ID & Variables Details */}
|
||
<div className="bg-gray-50 rounded-xl p-3 border border-gray-100 space-y-2.5 text-xs">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-gray-500 font-medium">شناسه پترن ملی پیامک:</span>
|
||
<span className="font-mono font-black text-purple-700 bg-purple-50 px-2 py-0.5 rounded border border-purple-200">
|
||
#{rule.patternId}
|
||
</span>
|
||
</div>
|
||
|
||
{matchedPattern && (
|
||
<div className="text-[11px] text-gray-600 bg-white p-2 rounded-lg border border-gray-200 leading-relaxed font-sans">
|
||
<span className="font-bold text-gray-700 block mb-0.5">متن الگو:</span>
|
||
{matchedPattern.body}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-1">
|
||
<span className="text-gray-500 font-medium block">ترتیب متغیرهای انتسابی:</span>
|
||
{rule.variables && rule.variables.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||
{rule.variables.map((vKey, vIdx) => {
|
||
const varMeta = eventDef?.variables.find((v) => v.key === vKey);
|
||
return (
|
||
<span
|
||
key={vIdx}
|
||
className="bg-white border border-gray-200 text-gray-700 text-[11px] font-mono font-bold px-2 py-0.5 rounded shadow-2xs flex items-center gap-1"
|
||
>
|
||
<span className="text-amber-600 font-black">{`{${vIdx}}`}:</span>
|
||
<span className="font-sans font-medium text-[10px]">
|
||
{varMeta ? varMeta.label : vKey}
|
||
</span>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-400 text-[11px]">بدون متغیر داینامیک</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom Action Footer */}
|
||
<div className="px-5 py-3 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||
<button
|
||
type="button"
|
||
disabled={isTestingRuleId === rule.id}
|
||
onClick={() => handleTestRule(rule)}
|
||
className="text-xs font-bold text-amber-700 hover:text-amber-900 flex items-center gap-1.5 transition-colors disabled:opacity-50"
|
||
>
|
||
{isTestingRuleId === rule.id ? (
|
||
<Spinner size="sm" className="text-amber-600" />
|
||
) : (
|
||
<Play className="w-3.5 h-3.5 fill-current" />
|
||
)}
|
||
<span>تست ارسال سناریو</span>
|
||
</button>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDuplicateRule(rule)}
|
||
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-transparent hover:border-gray-200 transition-all text-xs font-bold flex items-center gap-1"
|
||
title="تکثیر / ساخت کپی از سناریو"
|
||
>
|
||
<Copy className="w-3.5 h-3.5" />
|
||
<span>تکثیر</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenEditRuleModal(rule)}
|
||
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-white rounded-lg border border-transparent hover:border-gray-200 transition-all text-xs font-bold flex items-center gap-1"
|
||
title="ویرایش سناریو"
|
||
>
|
||
<Edit3 className="w-3.5 h-3.5" />
|
||
<span>ویرایش</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteRule(rule.id)}
|
||
className="p-1.5 text-gray-400 hover:text-rose-600 hover:bg-white rounded-lg border border-transparent hover:border-gray-200 transition-all"
|
||
title="حذف سناریو"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : activeTab === 'patterns' ? (
|
||
/* Patterns Management Tab */
|
||
<div className="space-y-6">
|
||
{/* Guide Banner for Multi-Pattern Scenarios */}
|
||
<div className="bg-gradient-to-r from-purple-50 via-blue-50 to-indigo-50 p-4 sm:p-5 rounded-2xl border border-purple-200/80 shadow-xs flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-purple-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<Zap className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h4 className="text-sm font-black text-purple-950 font-vazir mb-1">
|
||
ارسال چند پیامک همزمان با الگوهای مختلف روی یک رویداد
|
||
</h4>
|
||
<p className="text-xs text-purple-800/90 leading-relaxed font-sans">
|
||
برای یک رویداد (مانند <strong>«تایید و ثبت سفارش»</strong>)، میتوانید نامحدود الگوی پیامکی مجزا تعریف کنید: مثلاً یک پیامک به خریدار، یک پیامک اختصاصی به مدیر فروشگاه، و یک پیامک به شرکت حملونقل با شماره دلخواه! کافیست از دکمه <strong>«افزودن به سناریوها»</strong> در جلوی هر الگو استفاده نمایید.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('triggers')}
|
||
className="whitespace-nowrap px-3.5 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-xs font-bold transition-all shadow-sm shrink-0 flex items-center gap-1.5"
|
||
>
|
||
<span>مشاهده و مدیریت سناریوها</span>
|
||
<ArrowUpRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Action & Filter Bar */}
|
||
{(() => {
|
||
const trimmedFilter = patternFilter.trim().toLowerCase();
|
||
const filteredPatterns = patterns.filter((p) => {
|
||
if (patternStatusFilter === 'APPROVED' && p.status !== 1) return false;
|
||
if (patternStatusFilter === 'PENDING' && p.status !== 0) return false;
|
||
if (patternStatusFilter === 'REJECTED' && p.status !== 2 && p.status !== -1) return false;
|
||
|
||
if (!trimmedFilter) return true;
|
||
const matchesId = String(p.id).includes(trimmedFilter);
|
||
const matchesTitle = (p.title || '').toLowerCase().includes(trimmedFilter);
|
||
const matchesBody = (p.body || '').toLowerCase().includes(trimmedFilter);
|
||
const matchesAssigned = (p.assignedTo || []).some((a) => a.toLowerCase().includes(trimmedFilter));
|
||
return matchesId || matchesTitle || matchesBody || matchesAssigned;
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4">
|
||
{/* Search and Filters */}
|
||
<div className="flex flex-1 flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||
{/* Search Input */}
|
||
<div className="relative flex-1 max-w-md">
|
||
<div className="absolute inset-y-0 right-0 pr-3.5 flex items-center pointer-events-none text-gray-400">
|
||
<Search className="w-4 h-4" />
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={patternFilter}
|
||
onChange={(e) => setPatternFilter(e.target.value)}
|
||
placeholder="جستجو در متن، عنوان یا کد الگو..."
|
||
className="w-full pr-10 pl-9 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-medium text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-all"
|
||
/>
|
||
{patternFilter && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setPatternFilter('')}
|
||
className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 hover:text-gray-600"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div className="flex items-center gap-1.5 bg-gray-100/80 p-1 rounded-xl border border-gray-200/60 text-xs font-bold shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPatternStatusFilter('ALL')}
|
||
className={`px-3 py-1.5 rounded-lg transition-all ${
|
||
patternStatusFilter === 'ALL'
|
||
? 'bg-white text-purple-700 shadow-xs'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
همه ({patterns.length})
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPatternStatusFilter('APPROVED')}
|
||
className={`px-3 py-1.5 rounded-lg transition-all ${
|
||
patternStatusFilter === 'APPROVED'
|
||
? 'bg-white text-emerald-700 shadow-xs'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
تایید شده
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPatternStatusFilter('PENDING')}
|
||
className={`px-3 py-1.5 rounded-lg transition-all ${
|
||
patternStatusFilter === 'PENDING'
|
||
? 'bg-white text-amber-700 shadow-xs'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
در انتظار
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPatternStatusFilter('REJECTED')}
|
||
className={`px-3 py-1.5 rounded-lg transition-all ${
|
||
patternStatusFilter === 'REJECTED'
|
||
? 'bg-white text-rose-700 shadow-xs'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
نیازمند ویرایش
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions and Counter */}
|
||
<div className="flex items-center justify-between sm:justify-end gap-3 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={refreshPatterns}
|
||
disabled={isLoadingPatterns}
|
||
className="p-2.5 text-gray-600 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 className="hidden sm:inline">بروزرسانی از ملی پیامک</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsAddModalOpen(true)}
|
||
className="bg-purple-600 hover:bg-purple-700 text-white text-xs font-bold py-2.5 px-4 rounded-xl transition-all flex items-center gap-2 shadow-md shadow-purple-200"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>درج الگوی جدید</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter info banner if filtering */}
|
||
{(patternFilter || patternStatusFilter !== 'ALL') && (
|
||
<div className="flex items-center justify-between px-3 py-2 bg-purple-50/60 rounded-xl border border-purple-100 text-xs text-purple-900 font-medium">
|
||
<div className="flex items-center gap-2">
|
||
<Filter className="w-3.5 h-3.5 text-purple-600" />
|
||
<span>
|
||
نمایش <strong>{filteredPatterns.length}</strong> از <strong>{patterns.length}</strong> الگو
|
||
{patternFilter && (
|
||
<> برای عبارت «<span className="font-bold text-purple-700">{patternFilter}</span>»</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setPatternFilter('');
|
||
setPatternStatusFilter('ALL');
|
||
}}
|
||
className="text-purple-600 hover:text-purple-800 font-bold hover:underline text-[11px]"
|
||
>
|
||
پاک کردن فیلترها
|
||
</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">سناریوهای متصل (Triggers)</th>
|
||
<th className="p-4 text-center">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 font-medium">
|
||
{filteredPatterns.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={6} className="p-8 text-center text-gray-400 text-xs">
|
||
{patterns.length === 0
|
||
? 'هیچ الگویی یافت نشد. میتوانید با دکمه «درج الگوی جدید» اولین پترن خود را ثبت کنید.'
|
||
: 'هیچ الگویی با شرایط فیلتر جستجو شده مطابقت ندارد.'}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
filteredPatterns.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, 'کد پترن');
|
||
setCopiedId(p.id);
|
||
setTimeout(() => setCopiedId(null), 2000);
|
||
}}
|
||
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.5 max-w-xs">
|
||
{p.assignedTo.map((a, i) => (
|
||
<span
|
||
key={i}
|
||
className="inline-flex items-center gap-1 bg-purple-50 text-purple-800 text-[11px] font-bold px-2 py-0.5 rounded-lg border border-purple-200"
|
||
>
|
||
<Zap className="w-2.5 h-2.5 text-purple-600" />
|
||
<span>{a}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-400 text-[11px] italic">آزاد (بدون سناریو)</span>
|
||
)}
|
||
</td>
|
||
|
||
<td className="p-4 text-center">
|
||
<div className="flex items-center justify-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
handleOpenAddRuleModal(p.id, 'ORDER_PAID', `ارسال با الگوی ${p.id} - ${p.title}`);
|
||
}}
|
||
className="px-3 py-1.5 bg-amber-50 text-amber-800 hover:bg-amber-100 rounded-xl text-xs font-bold transition-all border border-amber-200 flex items-center gap-1.5 shadow-2xs"
|
||
title="اتصال این الگو به یک سناریوی ارسال پیامک (مانند ارسال به مشتری، ادمین، انبار یا شخص خاص)"
|
||
>
|
||
<Zap className="w-3.5 h-3.5 text-amber-600" />
|
||
<span>اتصال به سناریو</span>
|
||
</button>
|
||
|
||
<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-xl border border-gray-200 hover:border-blue-200 transition-all"
|
||
title="ویرایش متن الگو"
|
||
>
|
||
<Edit3 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : (
|
||
/* Logs & Tracking Tab */
|
||
<div className="space-y-6">
|
||
{/* Stats Bar */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-xl bg-purple-50 flex items-center justify-center text-purple-600">
|
||
<MessageSquare className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-gray-500 font-medium">کل پیامکهای ارسالی</span>
|
||
<h4 className="text-xl font-black text-gray-900 font-mono mt-0.5">{logStats.total}</h4>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
|
||
<CheckCircle2 className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-gray-500 font-medium">ارسالهای موفق</span>
|
||
<h4 className="text-xl font-black text-emerald-600 font-mono mt-0.5">{logStats.success}</h4>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-xl bg-rose-50 flex items-center justify-center text-rose-600">
|
||
<AlertCircle className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-gray-500 font-medium">ارسالهای ناموفق / خطا</span>
|
||
<h4 className="text-xl font-black text-rose-600 font-mono mt-0.5">{logStats.failed}</h4>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
|
||
<TrendingUp className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-gray-500 font-medium">نرخ موفقیت تحویل</span>
|
||
<h4 className="text-xl font-black text-blue-600 font-mono mt-0.5">{logStats.successRate}%</h4>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter and Actions Bar */}
|
||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 space-y-3">
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||
{/* Search */}
|
||
<div className="relative md:col-span-2">
|
||
<Search className="w-4 h-4 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
value={logSearch}
|
||
onChange={(e) => {
|
||
setLogSearch(e.target.value);
|
||
setLogPage(1);
|
||
}}
|
||
placeholder="جستجو در شماره گیرنده، کد پیگیری یا متن خطا..."
|
||
className="w-full pr-9 pl-3 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 text-xs font-medium"
|
||
/>
|
||
</div>
|
||
|
||
{/* Type Filter */}
|
||
<div>
|
||
<select
|
||
value={logTypeFilter}
|
||
onChange={(e) => {
|
||
setLogTypeFilter(e.target.value);
|
||
setLogPage(1);
|
||
}}
|
||
className="w-full border border-gray-200 rounded-xl p-2 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-bold bg-white"
|
||
>
|
||
<option value="ALL">همه انواع پیامک</option>
|
||
<option value="OTP">کد تایید ورود (OTP)</option>
|
||
<option value="ORDER_CONFIRMATION">ثبت و تایید سفارش</option>
|
||
<option value="SHIPPING_TRACKING">کد رهگیری پست</option>
|
||
<option value="B2B_NOTIFICATION">همکاران B2B</option>
|
||
<option value="PET_CARE_REMINDER">یادآور سلامت پت</option>
|
||
<option value="TEST">پیامک تستی</option>
|
||
<option value="GENERIC_TEXT">متن آزاد</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div>
|
||
<select
|
||
value={logStatusFilter}
|
||
onChange={(e) => {
|
||
setLogStatusFilter(e.target.value);
|
||
setLogPage(1);
|
||
}}
|
||
className="w-full border border-gray-200 rounded-xl p-2 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-bold bg-white"
|
||
>
|
||
<option value="ALL">همه وضعیتها</option>
|
||
<option value="SUCCESS">موفق (SUCCESS)</option>
|
||
<option value="FAILED">ناموفق (FAILED)</option>
|
||
<option value="DISABLED">غیرفعال (DISABLED)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 flex-wrap gap-2 text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={fetchLogs}
|
||
disabled={isLoadingLogs}
|
||
className="p-2 text-gray-600 hover:text-purple-600 hover:bg-purple-50 rounded-xl border border-gray-200 flex items-center gap-1.5 font-bold"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingLogs ? 'animate-spin text-purple-600' : ''}`} />
|
||
<span>بروزرسانی لاگها</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setLogSortOrder(logSortOrder === 'desc' ? 'asc' : 'desc')}
|
||
className="p-2 text-gray-600 hover:bg-gray-100 rounded-xl border border-gray-200 flex items-center gap-1 font-bold"
|
||
>
|
||
<Filter className="w-3.5 h-3.5" />
|
||
<span>مرتبسازی: {logSortOrder === 'desc' ? 'جدیدترین' : 'قدیمیترین'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleClearAllLogs}
|
||
className="text-rose-600 hover:bg-rose-50 border border-rose-200 px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
پاکسازی کل لاگها
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Logs Table */}
|
||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right text-sm">
|
||
<thead className="bg-gray-50 text-gray-600 font-bold border-b border-gray-200 text-xs">
|
||
<tr>
|
||
<th className="p-4">شماره گیرنده</th>
|
||
<th className="p-4">نوع پیامک</th>
|
||
<th className="p-4">کد پترن / متغیرها</th>
|
||
<th className="p-4">شناسه پیگیری (RecId)</th>
|
||
<th className="p-4">وضعیت</th>
|
||
<th className="p-4">تاریخ و ساعت</th>
|
||
<th className="p-4 text-center">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 font-medium">
|
||
{isLoadingLogs ? (
|
||
<tr>
|
||
<td colSpan={7} className="p-8 text-center text-gray-400">
|
||
<div className="flex justify-center"><Spinner size="md" className="text-purple-600" /></div>
|
||
</td>
|
||
</tr>
|
||
) : logs.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={7} className="p-8 text-center text-gray-400 text-xs">
|
||
هیچ رکورد لاگ پیامکی متناسب با جستجو یافت نشد.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
logs.map((log) => {
|
||
const typeInfo = getTypeName(log.type);
|
||
return (
|
||
<tr key={log.id} className="hover:bg-purple-50/30 transition-colors">
|
||
<td className="p-4 font-mono font-bold text-gray-900" dir="ltr">
|
||
<div className="flex items-center justify-end gap-1.5">
|
||
<span>{log.receptor}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyToClipboard(log.receptor, 'شماره')}
|
||
className="text-gray-400 hover:text-purple-600"
|
||
title="کپی شماره"
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
|
||
<td className="p-4">
|
||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold border ${typeInfo.bg}`}>
|
||
{typeInfo.label}
|
||
</span>
|
||
</td>
|
||
|
||
<td className="p-4 text-xs font-mono max-w-xs">
|
||
{(() => {
|
||
let snippet = log.messageText;
|
||
if (!snippet && log.patternId) {
|
||
const pat = patterns.find((p) => p.id === log.patternId);
|
||
if (pat && pat.body) {
|
||
let rendered = pat.body;
|
||
(log.args || []).forEach((val, idx) => {
|
||
rendered = rendered.replace(new RegExp(`\\{${idx}\\}`, 'g'), val || '');
|
||
});
|
||
snippet = rendered;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{log.patternId ? (
|
||
<div className="flex items-center gap-1.5 flex-wrap mb-1">
|
||
<span className="bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded font-bold">
|
||
#{log.patternId}
|
||
</span>
|
||
{log.args && log.args.length > 0 && (
|
||
<span className="text-gray-500 font-sans text-[10px]">
|
||
[{log.args.join(' , ')}]
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-400 font-sans text-[11px] block mb-1">متنی مستقیم</span>
|
||
)}
|
||
{snippet && (
|
||
<p className="font-sans text-[11px] text-gray-600 line-clamp-1 truncate" title={snippet}>
|
||
{snippet}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</td>
|
||
|
||
<td className="p-4 text-xs font-mono font-bold text-gray-700">
|
||
{log.recId ? (
|
||
<div className="flex items-center gap-1">
|
||
<span>{log.recId}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyToClipboard(log.recId || '', 'شناسه پیگیری')}
|
||
className="text-gray-400 hover:text-purple-600"
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-300">-</span>
|
||
)}
|
||
</td>
|
||
|
||
<td className="p-4 text-xs">
|
||
{log.status === 'SUCCESS' ? (
|
||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||
موفق
|
||
</span>
|
||
) : log.status === 'DISABLED' ? (
|
||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-slate-50 text-slate-700 border border-slate-200">
|
||
<Info className="w-3.5 h-3.5" />
|
||
غیرفعال
|
||
</span>
|
||
) : (
|
||
<span
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-rose-50 text-rose-700 border border-rose-200"
|
||
title={log.errorMessage || 'خطا در ارسال'}
|
||
>
|
||
<AlertCircle className="w-3.5 h-3.5" />
|
||
ناموفق
|
||
</span>
|
||
)}
|
||
</td>
|
||
|
||
<td className="p-4 text-xs text-gray-500 font-mono">
|
||
{formatDate(log.createdAt)}
|
||
</td>
|
||
|
||
<td className="p-4 text-center">
|
||
<div className="flex items-center justify-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedLog(log);
|
||
setIsLogDetailModalOpen(true);
|
||
}}
|
||
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||
title="مشاهده جزییات"
|
||
>
|
||
<Info className="w-4 h-4" />
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteLog(log.id)}
|
||
className="p-1.5 text-gray-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||
title="حذف لاگ"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination Controls */}
|
||
{logTotalPages > 1 && (
|
||
<div className="p-4 bg-gray-50 border-t border-gray-100 flex items-center justify-between text-xs">
|
||
<span className="text-gray-500 font-medium">
|
||
صفحه {logPage} از {logTotalPages}
|
||
</span>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={logPage <= 1}
|
||
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-40"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
disabled={logPage >= logTotalPages}
|
||
onClick={() => setLogPage((p) => Math.min(logTotalPages, p + 1))}
|
||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-40"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal: Add/Edit SMS Automation Rule */}
|
||
<Modal
|
||
isOpen={isRuleModalOpen}
|
||
onClose={() => setIsRuleModalOpen(false)}
|
||
title={editingRule ? 'ویرایش سناریوی پیامک' : 'تعریف سناریوی پیامکی جدید'}
|
||
icon={Zap}
|
||
maxWidth="2xl"
|
||
>
|
||
<form onSubmit={handleSaveRule} className="space-y-4">
|
||
{/* Rule Title */}
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">
|
||
عنوان سناریو (جهت تفکیک در لیست) *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={ruleFormTitle}
|
||
onChange={(e) => setRuleFormTitle(e.target.value)}
|
||
placeholder="مثلاً: ارسال مشخصات گیرنده به شرکت پست / انبار"
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-amber-500 text-sm font-bold"
|
||
/>
|
||
</div>
|
||
|
||
{/* Event & Recipient Grid */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
{/* Trigger Event */}
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">رویداد تحریککننده (Trigger Event) *</label>
|
||
<select
|
||
value={ruleFormEvent}
|
||
onChange={(e) => {
|
||
const newEv = e.target.value;
|
||
setRuleFormEvent(newEv);
|
||
const def = eventDefinitions.find((ed) => ed.event === newEv);
|
||
if (def && def.variables.length > 0) {
|
||
setRuleFormVariables(def.variables.slice(0, 3).map((v) => v.key));
|
||
}
|
||
}}
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-bold bg-white"
|
||
>
|
||
{eventDefinitions.length > 0 ? (
|
||
eventDefinitions.map((ed) => (
|
||
<option key={ed.event} value={ed.event}>
|
||
{ed.label}
|
||
</option>
|
||
))
|
||
) : (
|
||
<>
|
||
<option value="ORDER_PAID">پرداخت موفق سفارش (Order Paid)</option>
|
||
<option value="ORDER_CREATED">ثبت سفارش کارت به کارت / در انتظار</option>
|
||
<option value="ORDER_SHIPPED">ارسال مرسوله و صدور بارکد پستی</option>
|
||
<option value="WALLET_CHARGED">شارژ و افزایش موجودی کیف پول</option>
|
||
<option value="B2B_SUBMITTED">ثبت درخواست همکاری B2B</option>
|
||
<option value="CONTACT_SUBMITTED">ثبت پیام در فرم تماس با ما</option>
|
||
</>
|
||
)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Recipient Type */}
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">گیرنده پیامک (Recipient) *</label>
|
||
<select
|
||
value={ruleFormRecipientType}
|
||
onChange={(e) => setRuleFormRecipientType(e.target.value as any)}
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-bold bg-white"
|
||
>
|
||
<option value="customer">خریدار / مشتری (شماره ثبتشده در سفارش یا فرم)</option>
|
||
<option value="admin">مدیر سایت (شمارههای تنظیمشده در پنل)</option>
|
||
<option value="custom">شماره همراه دلخواه (انبار، واحد ارسال، حسابداری)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Custom Phone Number Input / User Picker if custom */}
|
||
{ruleFormRecipientType === 'custom' && (
|
||
<div className="bg-amber-50/70 p-4 rounded-xl border border-amber-200 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<label className="block text-xs font-bold text-amber-900">
|
||
شمارههای موبایل دریافتکننده (Custom Phone Numbers) *
|
||
</label>
|
||
<span className="text-[11px] text-amber-700 font-medium">
|
||
انتخاب از کاربران/مدیران سیستم یا درج مستقیم
|
||
</span>
|
||
</div>
|
||
|
||
{/* Searchable User Selector */}
|
||
<div className="relative">
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="w-3.5 h-3.5 absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
value={userSearchQuery}
|
||
onChange={(e) => {
|
||
const q = e.target.value;
|
||
setUserSearchQuery(q);
|
||
setIsUserDropdownOpen(true);
|
||
fetchSystemUsers(q);
|
||
}}
|
||
onFocus={() => {
|
||
setIsUserDropdownOpen(true);
|
||
if (systemUsers.length === 0) fetchSystemUsers();
|
||
}}
|
||
placeholder="جستجوی کاربر یا مدیر در سیستم (نام، شماره تماس)..."
|
||
className="w-full pr-8 pl-3 py-2 bg-white border border-amber-300 rounded-xl text-xs outline-none focus:ring-2 focus:ring-amber-500"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsUserDropdownOpen((prev) => !prev)}
|
||
className="px-3 py-2 bg-amber-200/70 hover:bg-amber-200 text-amber-900 rounded-xl text-xs font-bold shrink-0 transition-all flex items-center gap-1 cursor-pointer"
|
||
>
|
||
<Users className="w-3.5 h-3.5" />
|
||
<span>لیست کاربران</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Dropdown list */}
|
||
{isUserDropdownOpen && (
|
||
<div className="absolute z-50 mt-1.5 w-full bg-white border border-gray-200 rounded-xl shadow-xl max-h-56 overflow-y-auto divide-y divide-gray-100">
|
||
<div className="p-2 bg-gray-50 flex items-center justify-between text-[11px] text-gray-500 font-bold">
|
||
<span>انتخاب کاربر برای اضافه شدن به لیست شمارهها:</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsUserDropdownOpen(false)}
|
||
className="text-gray-400 hover:text-gray-700 cursor-pointer"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
{isLoadingUsers ? (
|
||
<div className="p-4 text-center text-xs text-gray-500">در حال بارگذاری کاربران...</div>
|
||
) : systemUsers.length === 0 ? (
|
||
<div className="p-4 text-center text-xs text-gray-500">کاربری یافت نشد</div>
|
||
) : (
|
||
systemUsers.map((u) => {
|
||
const uName = (u.firstName ? `${u.firstName} ${u.lastName || ''}` : 'بدون نام').trim();
|
||
const uPhone = u.mobile || '';
|
||
const isAlreadySelected = uPhone && ruleFormCustomPhone.includes(uPhone);
|
||
const roleBadge = u.role === 'Admin' ? 'مدیر' : u.role === 'Vet' ? 'دامپزشک' : 'کاربر';
|
||
|
||
return (
|
||
<button
|
||
key={u.id}
|
||
type="button"
|
||
onClick={() => {
|
||
if (!uPhone) {
|
||
toast.error('این کاربر شماره همراه ثبتشده ندارد');
|
||
return;
|
||
}
|
||
const currentPhones = ruleFormCustomPhone
|
||
.split(/[,;\s]+/)
|
||
.map((p) => p.trim())
|
||
.filter(Boolean);
|
||
if (currentPhones.includes(uPhone)) {
|
||
// Remove
|
||
const filtered = currentPhones.filter((p) => p !== uPhone);
|
||
setRuleFormCustomPhone(filtered.join(' ، '));
|
||
toast.success(`${uName} از گیرندگان حذف شد`);
|
||
} else {
|
||
// Add
|
||
const next = [...currentPhones, uPhone];
|
||
setRuleFormCustomPhone(next.join(' ، '));
|
||
toast.success(`${uName} (${uPhone}) به گیرندگان اضافه شد`);
|
||
}
|
||
}}
|
||
className={`w-full p-2.5 text-right flex items-center justify-between hover:bg-amber-50/50 transition-colors text-xs cursor-pointer ${
|
||
isAlreadySelected ? 'bg-amber-50' : ''
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-7 h-7 rounded-lg bg-amber-100 text-amber-800 font-bold flex items-center justify-center text-xs shrink-0">
|
||
{uName.charAt(0)}
|
||
</div>
|
||
<div>
|
||
<div className="font-bold text-gray-900 flex items-center gap-1.5">
|
||
<span>{uName}</span>
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-600 font-normal">
|
||
{roleBadge}
|
||
</span>
|
||
</div>
|
||
<div className="text-[11px] text-gray-500 font-mono" dir="ltr">
|
||
{uPhone || 'بدون شماره'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0">
|
||
{isAlreadySelected ? (
|
||
<span className="text-emerald-600 font-bold flex items-center gap-1 text-[11px]">
|
||
<Check className="w-3.5 h-3.5" /> انتخاب شده
|
||
</span>
|
||
) : (
|
||
<span className="text-purple-600 hover:underline text-[11px] font-bold">
|
||
+ افزودن
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={ruleFormCustomPhone}
|
||
onChange={(e) => setRuleFormCustomPhone(e.target.value)}
|
||
placeholder="مثلاً: 09123456789 (برای چند شماره با کاما یا ویرگول جدا کنید)"
|
||
className="w-full border border-amber-300 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<p className="text-[11px] text-amber-700">
|
||
💡 میتوانید از جستجوی بالا مدیر یا کاربر مدنظرتان را انتخاب کنید یا شمارهها را مستقیماً تایپ نمایید.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Pattern ID Selection */}
|
||
<div className="space-y-2 bg-gray-50 p-4 rounded-xl border border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-bold text-gray-800">
|
||
شناسه الگوی ملی پیامک (Pattern Body ID) *
|
||
</label>
|
||
<span className="text-[11px] text-gray-500">انتخاب از الگوها یا درج دستی</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
{/* Select from registered patterns */}
|
||
<div>
|
||
<select
|
||
value={ruleFormPatternId}
|
||
onChange={(e) => setRuleFormPatternId(e.target.value)}
|
||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-bold bg-white"
|
||
>
|
||
<option value="">انتخاب از لیست الگوهای موجود...</option>
|
||
{patterns.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
#{p.id} - {p.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Or Manual Body ID Input */}
|
||
<div>
|
||
<input
|
||
type="number"
|
||
required
|
||
value={ruleFormPatternId}
|
||
onChange={(e) => setRuleFormPatternId(e.target.value)}
|
||
placeholder="یا وارد کردن شناسه پترن جدید (مثلاً: 508081)"
|
||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-mono font-bold bg-white"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Show Preview of Selected Pattern Text if found */}
|
||
{(() => {
|
||
const selPat = patterns.find((p) => String(p.id) === String(ruleFormPatternId));
|
||
if (!selPat) return null;
|
||
return (
|
||
<div className="text-[11px] text-gray-600 bg-white p-2.5 rounded-lg border border-gray-200 leading-relaxed font-sans mt-1">
|
||
<span className="font-bold text-gray-700 block mb-0.5">متن الگو:</span>
|
||
{selPat.body}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Dynamic Variables Mapper */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-bold text-gray-800">
|
||
تخصیص متغیرها به ترتیب پارامترهای پترن ({'{0}'}, {'{1}'}, ...)
|
||
</label>
|
||
<span className="text-[11px] text-amber-700 font-medium">
|
||
کلیک روی متغیرها برای افزودن یا حذف
|
||
</span>
|
||
</div>
|
||
|
||
{/* Current mapped variables */}
|
||
<div className="bg-amber-50/40 p-3 rounded-xl border border-amber-200/80 min-h-[50px] flex items-center flex-wrap gap-2">
|
||
{ruleFormVariables.length === 0 ? (
|
||
<span className="text-xs text-gray-400 italic">
|
||
هیچ متغیری انتخاب نشده است. از لیست زیر متغیرهای مورد نظر را اضافه کنید.
|
||
</span>
|
||
) : (
|
||
ruleFormVariables.map((vKey, idx) => {
|
||
const curEventDef = eventDefinitions.find((ed) => ed.event === ruleFormEvent);
|
||
const varMeta = curEventDef?.variables.find((v) => v.key === vKey);
|
||
return (
|
||
<div
|
||
key={idx}
|
||
className="bg-white border border-amber-300 text-gray-800 text-xs font-bold px-2.5 py-1 rounded-lg shadow-2xs flex items-center gap-1.5 animate-in fade-in zoom-in duration-100"
|
||
>
|
||
<span className="text-amber-600 font-mono font-black">{`{${idx}}`}:</span>
|
||
<span>{varMeta ? varMeta.label : vKey}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [...ruleFormVariables];
|
||
updated.splice(idx, 1);
|
||
setRuleFormVariables(updated);
|
||
}}
|
||
className="text-gray-400 hover:text-rose-600 p-0.5 rounded"
|
||
title="حذف این متغیر"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Available Variables for the selected event */}
|
||
{(() => {
|
||
const curEventDef = eventDefinitions.find((ed) => ed.event === ruleFormEvent);
|
||
if (!curEventDef || curEventDef.variables.length === 0) return null;
|
||
|
||
return (
|
||
<div className="space-y-1.5 pt-1">
|
||
<span className="text-[11px] text-gray-500 font-bold block">
|
||
متغیرهای در دسترس برای رویداد «{curEventDef.label}»:
|
||
</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{curEventDef.variables.map((v) => (
|
||
<button
|
||
key={v.key}
|
||
type="button"
|
||
onClick={() => {
|
||
setRuleFormVariables([...ruleFormVariables, v.key]);
|
||
}}
|
||
className="text-[11px] bg-gray-100 hover:bg-amber-100 hover:text-amber-900 text-gray-700 px-2.5 py-1 rounded-lg border border-gray-200 transition-colors flex items-center gap-1 font-medium"
|
||
>
|
||
<Plus className="w-3 h-3 text-gray-500" />
|
||
<span>{v.label}</span>
|
||
<span className="text-[9px] text-gray-400 font-mono">({v.sample})</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Optional Description */}
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">
|
||
توضیحات و یادداشت داخلی (اختیاری)
|
||
</label>
|
||
<textarea
|
||
rows={2}
|
||
value={ruleFormDescription}
|
||
onChange={(e) => setRuleFormDescription(e.target.value)}
|
||
placeholder="مثلاً: ارسال فاکتور خرید و آدرس مشتری به واحد بستهبندی انبار تهران"
|
||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs leading-relaxed font-medium"
|
||
/>
|
||
</div>
|
||
|
||
{/* Enabled toggle */}
|
||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||
<span className="text-xs font-bold text-gray-800">فعال بودن این سناریو</span>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="sr-only peer"
|
||
checked={ruleFormEnabled}
|
||
onChange={(e) => setRuleFormEnabled(e.target.checked)}
|
||
/>
|
||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-600"></div>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Submit Buttons */}
|
||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsRuleModalOpen(false)}
|
||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-xs font-bold text-gray-600 hover:bg-gray-50 cursor-pointer"
|
||
>
|
||
انصراف
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="bg-amber-600 hover:bg-amber-700 text-white font-bold px-6 py-2.5 rounded-xl text-xs flex items-center gap-2 shadow-md shadow-amber-200 cursor-pointer"
|
||
>
|
||
<Save className="w-4 h-4" />
|
||
{editingRule ? 'ذخیره تغییرات سناریو' : 'ثبت و فعالسازی سناریو'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Modal: Add New Pattern */}
|
||
<Modal
|
||
isOpen={isAddModalOpen}
|
||
onClose={() => setIsAddModalOpen(false)}
|
||
title="درج الگوی جدید در سامانه ملی پیامک"
|
||
icon={Plus}
|
||
maxWidth="xl"
|
||
>
|
||
<form onSubmit={handleCreatePattern} className="space-y-4">
|
||
<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} به مبلغ {2} با موفقیت ثبت شد. کنینا ایران"
|
||
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"
|
||
variant="outline"
|
||
onClick={() => setIsAddModalOpen(false)}
|
||
className="px-5 py-2 text-xs font-bold"
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
disabled={isSubmittingPattern}
|
||
isLoading={isSubmittingPattern}
|
||
className="bg-purple-600 hover:bg-purple-700 text-white font-bold px-6 py-2 text-xs"
|
||
>
|
||
ارسال و ثبت الگو در ملی پیامک
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Modal: Edit Pattern */}
|
||
<Modal
|
||
isOpen={isEditModalOpen && !!editingPattern}
|
||
onClose={() => setIsEditModalOpen(false)}
|
||
title={`ویرایش متن الگو (BodyId: ${editingPattern?.id || ''})`}
|
||
icon={Edit3}
|
||
maxWidth="xl"
|
||
>
|
||
{editingPattern && (
|
||
<form onSubmit={handleEditPattern} className="space-y-4">
|
||
<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"
|
||
variant="outline"
|
||
onClick={() => setIsEditModalOpen(false)}
|
||
className="px-5 py-2 text-xs font-bold"
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
disabled={isSubmittingPattern}
|
||
isLoading={isSubmittingPattern}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold px-6 py-2 text-xs"
|
||
>
|
||
ذخیره ویرایش الگو
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* Modal: Log Details */}
|
||
<Modal
|
||
isOpen={isLogDetailModalOpen && !!selectedLog}
|
||
onClose={() => setIsLogDetailModalOpen(false)}
|
||
title="جزییات پیامک ارسالی"
|
||
icon={Info}
|
||
maxWidth="2xl"
|
||
footer={
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setIsLogDetailModalOpen(false)}
|
||
className="px-5 py-2 text-xs font-bold"
|
||
>
|
||
بستن
|
||
</Button>
|
||
}
|
||
>
|
||
{selectedLog && (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="grid grid-cols-2 gap-3 bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||
<div>
|
||
<span className="text-gray-400 font-medium">شماره گیرنده:</span>
|
||
<p className="font-mono font-bold text-gray-900 mt-0.5">{selectedLog.receptor}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 font-medium">نوع پیامک:</span>
|
||
<p className="font-bold text-gray-900 mt-0.5">{getTypeName(selectedLog.type).label}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 font-medium">شناسه الگو (Pattern ID):</span>
|
||
<p className="font-mono font-bold text-gray-900 mt-0.5">
|
||
{selectedLog.patternId ? `#${selectedLog.patternId}` : 'ارسال مستقیم'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 font-medium">کد رهگیری ملی پیامک:</span>
|
||
<p className="font-mono font-bold text-gray-900 mt-0.5">
|
||
{selectedLog.recId || 'ثبت نشده'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 font-medium">وضعیت ارسال:</span>
|
||
<p className="font-bold mt-0.5">
|
||
{selectedLog.status === 'SUCCESS' ? (
|
||
<span className="text-emerald-600">ارسال موفق (SUCCESS)</span>
|
||
) : (
|
||
<span className="text-rose-600">{selectedLog.status}</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 font-medium">تاریخ و زمان ثبت:</span>
|
||
<p className="font-mono font-bold text-gray-900 mt-0.5">{formatDate(selectedLog.createdAt)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Full SMS Message Text */}
|
||
{(() => {
|
||
let fullText = selectedLog.messageText;
|
||
if (!fullText && selectedLog.patternId) {
|
||
const pat = patterns.find((p) => p.id === selectedLog.patternId);
|
||
if (pat && pat.body) {
|
||
let rendered = pat.body;
|
||
(selectedLog.args || []).forEach((val, idx) => {
|
||
rendered = rendered.replace(new RegExp(`\\{${idx}\\}`, 'g'), val || '');
|
||
});
|
||
fullText = rendered;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<span className="font-bold text-gray-800 flex items-center gap-1.5">
|
||
<FileText className="w-3.5 h-3.5 text-purple-600" />
|
||
<span>متن کامل پیامک ارسالی:</span>
|
||
</span>
|
||
<div className="bg-purple-50/50 p-4 rounded-xl border border-purple-200/70 font-sans text-xs text-gray-800 leading-relaxed whitespace-pre-wrap select-text max-h-60 overflow-y-auto">
|
||
{fullText || (selectedLog.args && selectedLog.args.length > 0 ? selectedLog.args.join(' ') : 'متن ثبت نشده است.')}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{selectedLog.args && selectedLog.args.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<span className="font-bold text-gray-700">آرگومانها و مقادیر ارسالی به پترن:</span>
|
||
<div className="bg-slate-50 p-3.5 rounded-xl border border-slate-200 font-mono text-[11px] space-y-1.5 max-h-48 overflow-y-auto">
|
||
{selectedLog.args.map((arg, idx) => (
|
||
<div key={idx} className="flex items-center gap-2">
|
||
<span className="text-purple-600 font-bold">{`{${idx}}`}:</span>
|
||
<span className="text-gray-800 font-sans">{arg}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedLog.errorMessage && (
|
||
<div className="space-y-1.5">
|
||
<span className="font-bold text-rose-700">توضیحات خطا / پاسخ درگاه:</span>
|
||
<div className="bg-rose-50 text-rose-800 p-3.5 rounded-xl border border-rose-200 text-[11px] leading-relaxed max-h-36 overflow-y-auto">
|
||
{selectedLog.errorMessage}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|