import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon, PencilIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { SmsTemplate, SmsLog, SmsMessageText } from '../types'; import { formatDate, formatDateTime } from '../lib/utils'; import DataTable, { Column } from '../components/ui/DataTable'; import StatusBadge from '../components/ui/StatusBadge'; import Pagination from '../components/ui/Pagination'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import Modal from '../components/ui/Modal'; import SearchableSelect from '../components/ui/SearchableSelect'; const templateSchema = z.object({ name: z.string().min(2, 'نام قالب الزامی است'), body: z.string().min(5, 'متن قالب الزامی است'), }); type TemplateFormData = z.infer; type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-approved' | 'messages'; const STATUS_LOG_META: Record = { sent: { label: 'ارسال شده', cls: 'green' }, failed: { label: 'ناموفق', cls: 'red' }, queued: { label: 'در صف', cls: 'amber' }, }; const TAG_LABELS: Record = { global: 'سیستمی', otp: 'کد تأیید', payment: 'پرداخت', clinic_invitation: 'دعوت کلینیک', pre_registration: 'پیش‌ثبت‌نام', notification_mobile: 'تأیید موبایل', user_template: 'قالب کاربر', }; const TAG_FILTER_OPTIONS = [ { value: '', label: 'همه تگ‌ها' }, ...Object.entries(TAG_LABELS).map(([value, label]) => ({ value, label })), ]; // الگوی تمپلت کاوه‌نگار را از body (با {var}) و token_map (var→slot) می‌سازد: // هر {var} با %slot جایگزین می‌شود. function buildKavenegarPattern(body: string, tokenMap: Record): string { return body.replace(/\{([a-zA-Z0-9_]+)\}/g, (whole, key) => tokenMap[key] ? `%${tokenMap[key]}` : whole, ); } function KavenegarGuide({ msg }: { msg: SmsMessageText }) { const tokenMap = msg.token_map ?? {}; const pattern = buildKavenegarPattern(msg.body, tokenMap); const templateName = msg.kavenegar_template ?? ''; const copy = (text: string, label: string) => { navigator.clipboard.writeText(text).then( () => toast.success(`${label} کپی شد`), () => toast.error('کپی ناموفق بود'), ); }; return (
نحوه تعریف این تمپلت در پنل کاوه‌نگار
در پنل کاوه‌نگار → بخش «الگوها» یک الگوی جدید با نام و متن زیر بسازید و تأیید بگیرید. متن واقعی پیامک از همین الگو ارسال می‌شود.
نام الگو: {templateName || '—'}
متن الگو (با %token):
            {pattern}
          
{Object.keys(tokenMap).length > 0 && (
نگاشت متغیرها: {Object.entries(tokenMap).map(([k, slot]) => ( {`{${k}} → %${slot}`} ))}
)}
توجه: token، token2، token3 فاصله نمی‌پذیرند؛ مقادیر دارای فاصله در token10/token20 قرار می‌گیرند.
); } export default function SmsPage() { const qc = useQueryClient(); const [activeTab, setActiveTab] = useState('samples'); const [page, setPage] = useState(1); const [approveTarget, setApproveTarget] = useState(null); const [rejectTarget, setRejectTarget] = useState(null); const [rejectReason, setRejectReason] = useState(''); const [addOpen, setAddOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [postVisitRejectId, setPostVisitRejectId] = useState(null); const [postVisitRejectReason, setPostVisitRejectReason] = useState(''); const [tagFilter, setTagFilter] = useState(''); const [viewLog, setViewLog] = useState(null); const [editMsg, setEditMsg] = useState(null); const [editBody, setEditBody] = useState(''); const [editKaveName, setEditKaveName] = useState(''); const [editTemplate, setEditTemplate] = useState(null); const [editTplName, setEditTplName] = useState(''); const [editTplBody, setEditTplBody] = useState(''); const limit = 15; const sampleTemplatesQuery = useQuery({ queryKey: ['sms-samples', page], queryFn: () => api.get>( `/api/v1/admin/sms/sample-templates?status=approved&page=${page}&limit=${limit}`, ), enabled: activeTab === 'samples', }); const pendingTemplatesQuery = useQuery({ queryKey: ['sms-pending', activeTab === 'pending' ? page : 1], queryFn: () => api.get>( `/api/v1/admin/sms/sample-templates?status=pending&page=${activeTab === 'pending' ? page : 1}&limit=${limit}`, ), }); const logsQuery = useQuery({ queryKey: ['sms-logs', page, tagFilter], queryFn: () => api.get>( `/api/v1/admin/sms/logs?page=${page}&limit=${limit}` + (tagFilter ? `&tag=${tagFilter}` : ''), ), enabled: activeTab === 'logs', }); const messagesQuery = useQuery({ queryKey: ['sms-messages'], queryFn: () => api.get>('/api/v1/admin/sms/messages'), enabled: activeTab === 'messages', }); const updateMessageMut = useMutation({ mutationFn: ({ tag, body, kavenegarTemplate }: { tag: string; body: string; kavenegarTemplate: string }) => api.patch>(`/api/v1/admin/sms/messages/${tag}`, { body, kavenegar_template: kavenegarTemplate }), onSuccess: () => { toast.success('متن پیامک به‌روزرسانی شد'); setEditMsg(null); qc.invalidateQueries({ queryKey: ['sms-messages'] }); }, onError: (err: Error) => toast.error(err.message), }); const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(templateSchema), }); const createMutation = useMutation({ mutationFn: (d: TemplateFormData) => api.post>('/api/v1/sms/template', d), onSuccess: () => { toast.success('قالب اضافه شد'); setAddOpen(false); reset(); qc.invalidateQueries({ queryKey: ['sms-samples'] }); }, onError: (err: Error) => toast.error(err.message), }); const updateTemplateMut = useMutation({ mutationFn: ({ uuid, name, body }: { uuid: string; name: string; body: string }) => api.patch>(`/api/v1/sms/template/${uuid}`, { name, body }), onSuccess: () => { toast.success('قالب ویرایش شد'); setEditTemplate(null); qc.invalidateQueries({ queryKey: ['sms-samples'] }); }, onError: (err: Error) => toast.error(err.message), }); const approveMutation = useMutation({ mutationFn: (t: SmsTemplate) => api.post>(`/api/v1/admin/sms/template/${t.uuid}/approve`, {}), onSuccess: () => { toast.success('قالب تأیید شد'); setApproveTarget(null); qc.invalidateQueries({ queryKey: ['sms-pending'] }); }, onError: (err: Error) => toast.error(err.message), }); const rejectMutation = useMutation({ mutationFn: ({ t, reason }: { t: SmsTemplate; reason: string }) => api.post>(`/api/v1/admin/sms/template/${t.uuid}/reject`, { reason }), onSuccess: () => { toast.success('قالب رد شد'); setRejectTarget(null); setRejectReason(''); qc.invalidateQueries({ queryKey: ['sms-pending'] }); }, onError: (err: Error) => toast.error(err.message), }); const deleteMutation = useMutation({ mutationFn: (t: SmsTemplate) => api.delete>(`/api/v1/sms/template/${t.uuid}`), onSuccess: () => { toast.success('قالب حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['sms-samples'] }); }, onError: (err: Error) => toast.error(err.message), }); type PostVisitItem = { id: number; entity_type: string; entity_id: number; entity_name?: string | null; post_visit_text_pending: string; post_visit_text: string | null; post_visit_text_status: string }; const postVisitReviewQuery = useQuery>({ queryKey: ['sms-post-visit-review'], queryFn: () => api.get('/api/v1/admin/sms/settings/review?status=pending'), enabled: activeTab === 'pending', }); const postVisitApprovedQuery = useQuery>({ queryKey: ['sms-post-visit-approved'], queryFn: () => api.get('/api/v1/admin/sms/settings/review?status=approved'), enabled: activeTab === 'post-visit-approved', }); const postVisitApproveMutation = useMutation({ mutationFn: (id: number) => api.post(`/api/v1/admin/sms/settings/${id}/approve`, {}), onSuccess: () => { toast.success('متن پیامک تأیید شد'); qc.invalidateQueries({ queryKey: ['sms-post-visit-review'] }); qc.invalidateQueries({ queryKey: ['sms-post-visit-approved'] }); }, onError: (err: Error) => toast.error(err.message), }); const postVisitRejectMutation = useMutation({ mutationFn: ({ id, reason }: { id: number; reason: string }) => api.post(`/api/v1/admin/sms/settings/${id}/reject`, { reason }), onSuccess: () => { toast.success('متن پیامک رد شد'); setPostVisitRejectId(null); setPostVisitRejectReason(''); qc.invalidateQueries({ queryKey: ['sms-post-visit-review'] }); }, onError: (err: Error) => toast.error(err.message), }); const templateColumns: Column[] = [ { key: 'name', header: 'نام', render: (t) => {t.name} }, { key: 'body', header: 'محتوا', render: (t) => {t.body}, }, { key: 'status', header: 'وضعیت', render: (t) => }, { key: 'created_at', header: 'تاریخ', render: (t) => formatDate(t.created_at) }, ]; const pendingColumns: Column[] = templateColumns.filter((c) => c.key !== 'status'); const logColumns: Column[] = [ { key: 'recipient', header: 'گیرنده', render: (l) => {l.recipient} }, { key: 'message', header: 'پیام', render: (l) => ( ), }, { key: 'status', header: 'وضعیت', render: (l) => { const meta = STATUS_LOG_META[l.status] ?? { label: l.status, cls: 'gray' }; return {meta.label}; }, }, { key: 'tag', header: 'تگ', render: (l) => {TAG_LABELS[l.tag] ?? l.tag} }, { key: 'template', header: 'الگوی کاوه‌نگار', render: (l) => l.template ? {l.template} : }, { key: 'provider', header: 'سرویس‌دهنده' }, { key: 'sent_at', header: 'زمان ارسال', render: (l) => formatDateTime(l.sent_at) }, ]; const pendingCount = pendingTemplatesQuery.data?.meta?.totalRecords ?? 0; const handleTabChange = (tab: Tab) => { setActiveTab(tab); setPage(1); }; const postVisitPending = (postVisitReviewQuery.data?.data as any)?.data ?? []; const postVisitApproved = (postVisitApprovedQuery.data?.data as any)?.data ?? []; const pendingBadge = pendingCount + postVisitPending.length; const TABS: { key: Tab; label: string; badge?: number }[] = [ { key: 'samples', label: 'قالب‌های نمونه' }, { key: 'pending', label: 'در انتظار تأیید', badge: pendingBadge }, { key: 'post-visit-approved', label: 'پیامک‌های تأیید شده' }, { key: 'logs', label: 'لاگ‌های ارسال' }, { key: 'messages', label: 'متن پیامک‌ها' }, ]; return (

پیامک

مدیریت قالب‌های پیامک
{activeTab === 'samples' && ( )}
{/* Tabs */}
{TABS.map((t) => ( ))}
{activeTab === 'samples' && ( <> columns={templateColumns} data={sampleTemplatesQuery.data?.data ?? []} loading={sampleTemplatesQuery.isLoading} emptyMessage="هیچ قالبی یافت نشد" actions={(t) => ( <> )} /> )} {activeTab === 'pending' && ( <> {pendingCount > 0 && (
قالب‌های نمونه
)} columns={pendingColumns} data={pendingTemplatesQuery.data?.data ?? []} loading={pendingTemplatesQuery.isLoading} emptyMessage="قالب نمونه‌ای در انتظار تأیید نیست" actions={(t) => ( <> )} />
متن پیامک ویزیت
{postVisitReviewQuery.isLoading ? (
در حال بارگذاری...
) : postVisitPending.length === 0 ? (
متن ویزیتی در انتظار تأیید نیست
) : (
{postVisitPending.map((item: PostVisitItem) => (
{item.entity_type === 'doctor' ? 'پزشک' : item.entity_type === 'clinic' ? 'کلینیک' : item.entity_type} {item.entity_name ? `: ${item.entity_name}` : ` #${item.entity_id}`}
{item.post_visit_text_pending}
))}
)} )} {activeTab === 'post-visit-approved' && ( <> {postVisitApprovedQuery.isLoading ? (
در حال بارگذاری...
) : postVisitApproved.length === 0 ? (
متن تأییدشده‌ای وجود ندارد
) : (
{postVisitApproved.map((item: PostVisitItem) => (
{item.entity_type === 'doctor' ? 'پزشک' : item.entity_type === 'clinic' ? 'کلینیک' : item.entity_type} {item.entity_name ? `: ${item.entity_name}` : ` #${item.entity_id}`} تأیید شده
{item.post_visit_text}
))}
)} )} {activeTab === 'logs' && ( <>
({ value: o.value, label: o.label }))} value={tagFilter} onChange={(v) => { setTagFilter(v ? String(v) : ''); setPage(1); }} placeholder="همه تگ‌ها" />
columns={logColumns} data={logsQuery.data?.data ?? []} loading={logsQuery.isLoading} emptyMessage="لاگی یافت نشد" /> )} {activeTab === 'messages' && (
{(((messagesQuery.data?.data as any)?.data ?? []) as SmsMessageText[]).map((m) => (
{m.title}
{m.body}
تمپلت کاوه‌نگار: {m.kavenegar_template || 'تعریف‌نشده'}
{m.variables.length > 0 && (
{m.variables.map((v) => {`{${v}}`})}
)}
))} {messagesQuery.isLoading &&

در حال بارگذاری...

}
)}
setEditMsg(null)} footer={ <> } >