feat: Add tagging system for SMS logs and templates
- 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.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
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 } from '../types';
|
||||
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';
|
||||
@@ -21,7 +21,7 @@ const templateSchema = z.object({
|
||||
});
|
||||
type TemplateFormData = z.infer<typeof templateSchema>;
|
||||
|
||||
type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-review';
|
||||
type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-review' | 'messages';
|
||||
|
||||
const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
|
||||
sent: { label: 'ارسال شده', cls: 'green' },
|
||||
@@ -29,6 +29,21 @@ const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
|
||||
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');
|
||||
@@ -40,6 +55,9 @@ export default function SmsPage() {
|
||||
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({
|
||||
@@ -60,12 +78,31 @@ export default function SmsPage() {
|
||||
});
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['sms-logs', page],
|
||||
queryKey: ['sms-logs', page, tagFilter],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<SmsLog>>(`/api/v1/admin/sms/logs?page=${page}&limit=${limit}`),
|
||||
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),
|
||||
});
|
||||
@@ -171,6 +208,7 @@ export default function SmsPage() {
|
||||
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) },
|
||||
];
|
||||
@@ -189,6 +227,7 @@ export default function SmsPage() {
|
||||
{ key: 'pending', label: 'در انتظار تأیید', badge: pendingCount },
|
||||
{ key: 'post-visit-review', label: 'متن پیامک ویزیت', badge: postVisitPendingCount },
|
||||
{ key: 'logs', label: 'لاگهای ارسال' },
|
||||
{ key: 'messages', label: 'متن پیامکها' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -338,6 +377,18 @@ export default function SmsPage() {
|
||||
|
||||
{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 ?? []}
|
||||
@@ -352,9 +403,60 @@ export default function SmsPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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={
|
||||
<>
|
||||
|
||||
@@ -183,10 +183,19 @@ export interface SmsLog {
|
||||
message: string;
|
||||
status: 'queued' | 'sent' | 'failed';
|
||||
provider: string;
|
||||
tag: string;
|
||||
sent_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SmsMessageText {
|
||||
tag: string;
|
||||
title: string;
|
||||
body: string;
|
||||
variables: string[];
|
||||
updated_at: number | null;
|
||||
}
|
||||
|
||||
export interface Province {
|
||||
id: number;
|
||||
uuid: string;
|
||||
|
||||
Reference in New Issue
Block a user