Files
clinicpro/assets/admin/pages/SmsPage.tsx
T

354 lines
14 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon } 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 { 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';
const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
sent: { label: 'ارسال شده', cls: 'green' },
failed: { label: 'ناموفق', cls: 'red' },
queued: { label: 'در صف', cls: 'amber' },
};
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 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],
queryFn: () =>
api.get<PaginatedResponse<SmsLog>>(`/api/v1/admin/sms/logs?page=${page}&limit=${limit}`),
enabled: activeTab === 'logs',
});
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 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: '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 TABS: { key: Tab; label: string; badge?: number }[] = [
{ key: 'samples', label: 'قالب‌های نمونه' },
{ key: 'pending', label: 'در انتظار تأیید', badge: pendingCount },
{ key: 'logs', 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 === 'logs' && (
<>
<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}
/>
</>
)}
</div>
</div>
<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>
<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>
);
}