343 lines
13 KiB
TypeScript
343 lines
13 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 PageHeader from '../components/ui/PageHeader';
|
|
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';
|
|
|
|
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) => <span className="font-medium">{t.name}</span> },
|
|
{
|
|
key: 'body',
|
|
header: 'محتوا',
|
|
render: (t) => <span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{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="text-xs text-gray-500 line-clamp-1 max-w-xs">{l.message}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'وضعیت',
|
|
render: (l) => (
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
l.status === 'sent' ? 'bg-green-100 text-green-700'
|
|
: l.status === 'failed' ? 'bg-red-100 text-red-700'
|
|
: 'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{l.status === 'sent' ? 'ارسال شده' : l.status === 'failed' ? 'ناموفق' : 'در صف'}
|
|
</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);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="پیامک"
|
|
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'پیامک' }]}
|
|
action={
|
|
activeTab === 'samples' ? (
|
|
<button onClick={() => setAddOpen(true)}
|
|
className="cp-btn-primary">
|
|
<PlusIcon className="w-4 h-4" />
|
|
افزودن قالب
|
|
</button>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
<div className="cp-card">
|
|
<div className="flex border-b border-slate-200 dark:border-gray-700 px-6 pt-4">
|
|
{([
|
|
{ key: 'samples' as Tab, label: 'قالبهای نمونه' },
|
|
{ key: 'pending' as Tab, label: 'در انتظار تأیید', badge: pendingCount },
|
|
{ key: 'logs' as Tab, label: 'لاگهای ارسال' },
|
|
]).map((t) => (
|
|
<button key={t.key} onClick={() => handleTabChange(t.key)}
|
|
className={`pb-3 px-4 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-1.5 ${
|
|
activeTab === t.key
|
|
? 'border-primary-600 text-primary-600'
|
|
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
|
|
}`}>
|
|
{t.label}
|
|
{t.badge != null && t.badge > 0 && (
|
|
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold bg-orange-500 text-white leading-none">
|
|
{t.badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
{activeTab === 'samples' && (
|
|
<>
|
|
<DataTable<SmsTemplate>
|
|
columns={templateColumns}
|
|
data={sampleTemplatesQuery.data?.data ?? []}
|
|
loading={sampleTemplatesQuery.isLoading}
|
|
emptyMessage="هیچ قالبی یافت نشد"
|
|
actions={(t) => (
|
|
<button onClick={() => setDeleteTarget(t)}
|
|
className="cp-action-delete" title="حذف">
|
|
<TrashIcon className="w-4 h-4" />
|
|
</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="cp-action-approve" title="تأیید">
|
|
<CheckIcon className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => setRejectTarget(t)}
|
|
className="cp-action-delete" title="رد">
|
|
<XMarkIcon className="w-4 h-4" />
|
|
</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="cp-btn-secondary">
|
|
لغو
|
|
</button>
|
|
<button form="add-sms-form" type="submit" disabled={isSubmitting}
|
|
className="cp-btn-primary">
|
|
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<form id="add-sms-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
|
<div>
|
|
<label className="cp-label">نام قالب</label>
|
|
<input {...register('name')} placeholder="مثال: تأیید نوبت"
|
|
className="cp-input h-11" />
|
|
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
|
</div>
|
|
<div>
|
|
<label className="cp-label">متن قالب</label>
|
|
<textarea {...register('body')} rows={4} placeholder="متن پیامک..."
|
|
className="cp-textarea resize-none" />
|
|
{errors.body && <p className="text-red-500 text-xs mt-1">{errors.body.message}</p>}
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal open={!!rejectTarget} title="رد قالب پیامک"
|
|
onClose={() => { setRejectTarget(null); setRejectReason(''); }}
|
|
footer={
|
|
<>
|
|
<button onClick={() => { setRejectTarget(null); setRejectReason(''); }}
|
|
className="cp-btn-secondary">
|
|
لغو
|
|
</button>
|
|
<button
|
|
onClick={() => rejectTarget && rejectMutation.mutate({ t: rejectTarget, reason: rejectReason })}
|
|
disabled={!rejectReason || rejectMutation.isPending}
|
|
className="cp-btn-danger">
|
|
رد کردن
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<label className="cp-label mb-2">دلیل رد:</label>
|
|
<textarea value={rejectReason} onChange={(e) => setRejectReason(e.target.value)}
|
|
rows={4} placeholder="دلیل رد را بنویسید..."
|
|
className="cp-textarea resize-none" />
|
|
</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>
|
|
);
|
|
}
|