- Introduced a `tag` field in the `SmsLog` entity to categorize SMS messages. - Updated the `SmsService` to handle the new `tag` parameter during SMS dispatch. - Implemented a `SmsTextResolver` service to resolve SMS message templates based on tags. - Created a new `SmsMessageTemplate` entity for editable SMS templates with placeholders. - Added endpoints for managing SMS message templates in the admin panel. - Enhanced existing SMS dispatching methods across various controllers to utilize the tagging system. - Migrated the database to include the new `tag` field and created a seeding command for default SMS templates. - Updated admin API to filter SMS logs by tag and include tag information in responses.
552 lines
23 KiB
TypeScript
552 lines
23 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon, PencilIcon } 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';
|
|
|
|
const templateSchema = z.object({
|
|
name: z.string().min(2, 'نام قالب الزامی است'),
|
|
body: z.string().min(5, 'متن قالب الزامی است'),
|
|
});
|
|
type TemplateFormData = z.infer<typeof templateSchema>;
|
|
|
|
type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-review' | 'messages';
|
|
|
|
const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
|
|
sent: { label: 'ارسال شده', cls: 'green' },
|
|
failed: { label: 'ناموفق', cls: 'red' },
|
|
queued: { label: 'در صف', cls: 'amber' },
|
|
};
|
|
|
|
const TAG_LABELS: Record<string, string> = {
|
|
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 })),
|
|
];
|
|
|
|
export default function SmsPage() {
|
|
const qc = useQueryClient();
|
|
const [activeTab, setActiveTab] = useState<Tab>('samples');
|
|
const [page, setPage] = useState(1);
|
|
const [approveTarget, setApproveTarget] = useState<SmsTemplate | null>(null);
|
|
const [rejectTarget, setRejectTarget] = useState<SmsTemplate | null>(null);
|
|
const [rejectReason, setRejectReason] = useState('');
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<SmsTemplate | null>(null);
|
|
const [postVisitRejectId, setPostVisitRejectId] = useState<number | null>(null);
|
|
const [postVisitRejectReason, setPostVisitRejectReason] = useState('');
|
|
const [tagFilter, setTagFilter] = useState('');
|
|
const [editMsg, setEditMsg] = useState<SmsMessageText | null>(null);
|
|
const [editBody, setEditBody] = useState('');
|
|
const limit = 15;
|
|
|
|
const sampleTemplatesQuery = useQuery({
|
|
queryKey: ['sms-samples', page],
|
|
queryFn: () =>
|
|
api.get<PaginatedResponse<SmsTemplate>>(
|
|
`/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<PaginatedResponse<SmsTemplate>>(
|
|
`/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<PaginatedResponse<SmsLog>>(
|
|
`/api/v1/admin/sms/logs?page=${page}&limit=${limit}` + (tagFilter ? `&tag=${tagFilter}` : ''),
|
|
),
|
|
enabled: activeTab === 'logs',
|
|
});
|
|
|
|
const messagesQuery = useQuery({
|
|
queryKey: ['sms-messages'],
|
|
queryFn: () => api.get<ApiResponse<{ data: SmsMessageText[] }>>('/api/v1/admin/sms/messages'),
|
|
enabled: activeTab === 'messages',
|
|
});
|
|
|
|
const updateMessageMut = useMutation({
|
|
mutationFn: ({ tag, body }: { tag: string; body: string }) =>
|
|
api.patch<ApiResponse<SmsMessageText>>(`/api/v1/admin/sms/messages/${tag}`, { body }),
|
|
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<TemplateFormData>({
|
|
resolver: zodResolver(templateSchema),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (d: TemplateFormData) =>
|
|
api.post<ApiResponse<SmsTemplate>>('/api/v1/sms/template', d),
|
|
onSuccess: () => {
|
|
toast.success('قالب اضافه شد');
|
|
setAddOpen(false);
|
|
reset();
|
|
qc.invalidateQueries({ queryKey: ['sms-samples'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: (t: SmsTemplate) =>
|
|
api.post<ApiResponse<null>>(`/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<ApiResponse<null>>(`/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<ApiResponse<null>>(`/api/v1/sms/template/${t.uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('قالب حذف شد');
|
|
setDeleteTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['sms-samples'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const postVisitReviewQuery = useQuery<ApiResponse<{ data: Array<{ id: number; entity_type: string; entity_id: number; post_visit_text_pending: string; post_visit_text_status: string }> }>>({
|
|
queryKey: ['sms-post-visit-review'],
|
|
queryFn: () => api.get('/api/v1/admin/sms/settings/review'),
|
|
enabled: activeTab === 'post-visit-review',
|
|
});
|
|
|
|
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'] });
|
|
},
|
|
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<SmsTemplate>[] = [
|
|
{ key: 'name', header: 'نام', render: (t) => <b>{t.name}</b> },
|
|
{
|
|
key: 'body',
|
|
header: 'محتوا',
|
|
render: (t) => <span className="muted" style={{ fontSize: 12, display: 'block', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.body}</span>,
|
|
},
|
|
{ key: 'status', header: 'وضعیت', render: (t) => <StatusBadge type="sms" value={t.status} /> },
|
|
{ key: 'created_at', header: 'تاریخ', render: (t) => formatDate(t.created_at) },
|
|
];
|
|
|
|
const pendingColumns: Column<SmsTemplate>[] = templateColumns.filter((c) => c.key !== 'status');
|
|
|
|
const logColumns: Column<SmsLog>[] = [
|
|
{ key: 'recipient', header: 'گیرنده', render: (l) => <span dir="ltr">{l.recipient}</span> },
|
|
{
|
|
key: 'message',
|
|
header: 'پیام',
|
|
render: (l) => <span className="muted" style={{ fontSize: 12, display: 'block', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.message}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'وضعیت',
|
|
render: (l) => {
|
|
const meta = STATUS_LOG_META[l.status] ?? { label: l.status, cls: 'gray' };
|
|
return <span className={`badge ${meta.cls}`}><span className="bdot" />{meta.label}</span>;
|
|
},
|
|
},
|
|
{ key: 'tag', header: 'تگ', render: (l) => <span className="badge gray"><span className="bdot" />{TAG_LABELS[l.tag] ?? l.tag}</span> },
|
|
{ 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 postVisitPendingCount = (postVisitReviewQuery.data?.data as any)?.data?.length ?? 0;
|
|
|
|
const TABS: { key: Tab; label: string; badge?: number }[] = [
|
|
{ key: 'samples', label: 'قالبهای نمونه' },
|
|
{ key: 'pending', label: 'در انتظار تأیید', badge: pendingCount },
|
|
{ key: 'post-visit-review', label: 'متن پیامک ویزیت', badge: postVisitPendingCount },
|
|
{ key: 'logs', label: 'لاگهای ارسال' },
|
|
{ key: 'messages', label: 'متن پیامکها' },
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">پیامک</h1>
|
|
<div className="muted">مدیریت قالبهای پیامک</div>
|
|
</div>
|
|
{activeTab === 'samples' && (
|
|
<button onClick={() => setAddOpen(true)} className="btn primary sm">
|
|
<PlusIcon style={{ width: 16, height: 16 }} />
|
|
افزودن قالب
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
{/* Tabs */}
|
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', padding: '0 var(--card-pad)' }}>
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => handleTabChange(t.key)}
|
|
style={{
|
|
padding: '14px 16px',
|
|
fontSize: 14,
|
|
fontWeight: 500,
|
|
border: 'none',
|
|
background: 'none',
|
|
cursor: 'pointer',
|
|
borderBottom: `2px solid ${activeTab === t.key ? 'var(--primary)' : 'transparent'}`,
|
|
color: activeTab === t.key ? 'var(--primary)' : 'var(--text-2)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
marginBottom: -1,
|
|
transition: 'color .15s',
|
|
}}
|
|
>
|
|
{t.label}
|
|
{t.badge != null && t.badge > 0 && (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
minWidth: 18, height: 18, padding: '0 4px', borderRadius: 9,
|
|
fontSize: 10, fontWeight: 700,
|
|
background: 'oklch(0.62 0.22 30)', color: '#fff', lineHeight: 1,
|
|
}}>
|
|
{t.badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="card-pad" style={{ paddingTop: 'var(--card-pad)' }}>
|
|
{activeTab === 'samples' && (
|
|
<>
|
|
<DataTable<SmsTemplate>
|
|
columns={templateColumns}
|
|
data={sampleTemplatesQuery.data?.data ?? []}
|
|
loading={sampleTemplatesQuery.isLoading}
|
|
emptyMessage="هیچ قالبی یافت نشد"
|
|
actions={(t) => (
|
|
<button onClick={() => setDeleteTarget(t)} className="mini-btn danger" title="حذف">
|
|
<TrashIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
)}
|
|
/>
|
|
<Pagination
|
|
page={page}
|
|
total={sampleTemplatesQuery.data?.meta?.totalRecords ?? 0}
|
|
limit={limit}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'pending' && (
|
|
<>
|
|
<DataTable<SmsTemplate>
|
|
columns={pendingColumns}
|
|
data={pendingTemplatesQuery.data?.data ?? []}
|
|
loading={pendingTemplatesQuery.isLoading}
|
|
emptyMessage="هیچ قالبی در انتظار تأیید نیست"
|
|
actions={(t) => (
|
|
<>
|
|
<button onClick={() => setApproveTarget(t)} className="mini-btn" title="تأیید">
|
|
<CheckIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button onClick={() => setRejectTarget(t)} className="mini-btn danger" title="رد">
|
|
<XMarkIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
</>
|
|
)}
|
|
/>
|
|
<Pagination
|
|
page={page}
|
|
total={pendingTemplatesQuery.data?.meta?.totalRecords ?? 0}
|
|
limit={limit}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'post-visit-review' && (
|
|
<div style={{ padding: '16px' }}>
|
|
{postVisitReviewQuery.isLoading ? (
|
|
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
|
) : ((postVisitReviewQuery.data?.data as any)?.data ?? []).length === 0 ? (
|
|
<div style={{ textAlign: 'center', padding: '32px 16px', color: 'var(--text-3)', fontSize: 13 }}>
|
|
متنی برای بررسی وجود ندارد
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
{((postVisitReviewQuery.data?.data as any)?.data ?? []).map((item: any) => (
|
|
<div key={item.id} className="card card-pad">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
|
|
<div>
|
|
<span className="badge gray" style={{ fontSize: 11 }}>{item.entity_type} #{item.entity_id}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={postVisitApproveMutation.isPending}
|
|
onClick={() => postVisitApproveMutation.mutate(item.id)}
|
|
>
|
|
<CheckIcon style={{ width: 14 }} /> تأیید
|
|
</button>
|
|
<button
|
|
className="btn danger sm"
|
|
onClick={() => { setPostVisitRejectId(item.id); setPostVisitRejectReason(''); }}
|
|
>
|
|
<XMarkIcon style={{ width: 14 }} /> رد
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: 13, lineHeight: 1.8, padding: '10px 14px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--border)', direction: 'rtl' }}>
|
|
{item.post_visit_text_pending}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'logs' && (
|
|
<>
|
|
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'flex-end' }}>
|
|
<select
|
|
className="input"
|
|
style={{ maxWidth: 200 }}
|
|
value={tagFilter}
|
|
onChange={(e) => { setTagFilter(e.target.value); setPage(1); }}
|
|
>
|
|
{TAG_FILTER_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<DataTable<SmsLog>
|
|
columns={logColumns}
|
|
data={logsQuery.data?.data ?? []}
|
|
loading={logsQuery.isLoading}
|
|
emptyMessage="لاگی یافت نشد"
|
|
/>
|
|
<Pagination
|
|
page={page}
|
|
total={logsQuery.data?.meta?.totalRecords ?? 0}
|
|
limit={limit}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'messages' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{(((messagesQuery.data?.data as any)?.data ?? []) as SmsMessageText[]).map((m) => (
|
|
<div key={m.tag} className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<b style={{ fontSize: 14 }}>{m.title}</b>
|
|
<button className="btn ghost sm" onClick={() => { setEditMsg(m); setEditBody(m.body); }}>
|
|
<PencilIcon style={{ width: 14, height: 14 }} /> ویرایش
|
|
</button>
|
|
</div>
|
|
<div className="muted" style={{ fontSize: 13, lineHeight: 1.9, whiteSpace: 'pre-wrap', direction: 'rtl' }}>{m.body}</div>
|
|
{m.variables.length > 0 && (
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
{m.variables.map((v) => <span key={v} className="chip" dir="ltr">{`{${v}}`}</span>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{messagesQuery.isLoading && <p className="muted">در حال بارگذاری...</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
open={editMsg !== null}
|
|
title={`ویرایش متن: ${editMsg?.title ?? ''}`}
|
|
onClose={() => setEditMsg(null)}
|
|
footer={
|
|
<>
|
|
<button className="btn ghost sm" onClick={() => setEditMsg(null)}>انصراف</button>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={updateMessageMut.isPending || !editBody.trim()}
|
|
onClick={() => editMsg && updateMessageMut.mutate({ tag: editMsg.tag, body: editBody })}
|
|
>
|
|
{updateMessageMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="form-row">
|
|
<label>متن پیامک</label>
|
|
<textarea className="input" rows={5} dir="rtl" value={editBody} onChange={(e) => setEditBody(e.target.value)} />
|
|
</div>
|
|
{editMsg && editMsg.variables.length > 0 && (
|
|
<div style={{ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<span className="muted" style={{ fontSize: 12 }}>متغیرهای مجاز:</span>
|
|
{editMsg.variables.map((v) => <span key={v} className="chip" dir="ltr">{`{${v}}`}</span>)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
<Modal open={addOpen} title="افزودن قالب نمونه" onClose={() => setAddOpen(false)}
|
|
footer={
|
|
<>
|
|
<button onClick={() => setAddOpen(false)} className="btn ghost sm">لغو</button>
|
|
<button form="add-sms-form" type="submit" disabled={isSubmitting} className="btn primary sm">
|
|
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<form id="add-sms-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
|
|
<div className="form-row">
|
|
<label>نام قالب</label>
|
|
<input {...register('name')} placeholder="مثال: تأیید نوبت" className="input" />
|
|
{errors.name && <p className="err-text">{errors.name.message}</p>}
|
|
</div>
|
|
<div className="form-row" style={{ marginTop: 12 }}>
|
|
<label>متن قالب</label>
|
|
<textarea {...register('body')} rows={4} placeholder="متن پیامک..."
|
|
className="input" style={{ resize: 'none', height: 'auto' }} />
|
|
{errors.body && <p className="err-text">{errors.body.message}</p>}
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal open={!!rejectTarget} title="رد قالب پیامک"
|
|
onClose={() => { setRejectTarget(null); setRejectReason(''); }}
|
|
footer={
|
|
<>
|
|
<button onClick={() => { setRejectTarget(null); setRejectReason(''); }} className="btn ghost sm">لغو</button>
|
|
<button
|
|
onClick={() => rejectTarget && rejectMutation.mutate({ t: rejectTarget, reason: rejectReason })}
|
|
disabled={!rejectReason || rejectMutation.isPending}
|
|
className="btn danger sm">
|
|
رد کردن
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="form-row">
|
|
<label>دلیل رد</label>
|
|
<textarea value={rejectReason} onChange={(e) => setRejectReason(e.target.value)}
|
|
rows={4} placeholder="دلیل رد را بنویسید..."
|
|
className="input" style={{ resize: 'none', height: 'auto' }} />
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal open={!!postVisitRejectId} title="رد متن پیامک ویزیت"
|
|
onClose={() => { setPostVisitRejectId(null); setPostVisitRejectReason(''); }}
|
|
footer={
|
|
<>
|
|
<button onClick={() => { setPostVisitRejectId(null); setPostVisitRejectReason(''); }} className="btn ghost sm">لغو</button>
|
|
<button
|
|
onClick={() => postVisitRejectId && postVisitRejectMutation.mutate({ id: postVisitRejectId, reason: postVisitRejectReason })}
|
|
disabled={!postVisitRejectReason || postVisitRejectMutation.isPending}
|
|
className="btn danger sm">
|
|
رد کردن
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="form-row">
|
|
<label>دلیل رد</label>
|
|
<textarea value={postVisitRejectReason} onChange={(e) => setPostVisitRejectReason(e.target.value)}
|
|
rows={4} placeholder="دلیل رد را بنویسید..."
|
|
className="input" style={{ resize: 'none', height: 'auto' }} />
|
|
</div>
|
|
</Modal>
|
|
|
|
<ConfirmDialog
|
|
open={!!approveTarget}
|
|
title="تأیید قالب پیامک"
|
|
message={`قالب "${approveTarget?.name}" را تأیید میکنید؟`}
|
|
confirmLabel="تأیید"
|
|
loading={approveMutation.isPending}
|
|
onConfirm={() => approveTarget && approveMutation.mutate(approveTarget)}
|
|
onCancel={() => setApproveTarget(null)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title="حذف قالب"
|
|
message={`آیا از حذف قالب "${deleteTarget?.name}" اطمینان دارید؟`}
|
|
confirmLabel="حذف"
|
|
danger
|
|
loading={deleteMutation.isPending}
|
|
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|