feat: implement admin API for user and representation management
- Updated UsersPage to fetch users from the new admin endpoint. - Enhanced user data structure to include 'name' and modified rendering logic. - Added RepresentationDetailPage for detailed representation management. - Created AdminApiController to handle user and representation CRUD operations. - Implemented pagination and search functionality for users and representations. - Updated user and representation data models to reflect new API structure.
This commit is contained in:
@@ -16,6 +16,7 @@ import PaymentsPage from './pages/PaymentsPage';
|
|||||||
import PaymentDetailPage from './pages/PaymentDetailPage';
|
import PaymentDetailPage from './pages/PaymentDetailPage';
|
||||||
import SettlementsPage from './pages/SettlementsPage';
|
import SettlementsPage from './pages/SettlementsPage';
|
||||||
import RepresentationsPage from './pages/RepresentationsPage';
|
import RepresentationsPage from './pages/RepresentationsPage';
|
||||||
|
import RepresentationDetailPage from './pages/RepresentationDetailPage';
|
||||||
import CommentsPage from './pages/CommentsPage';
|
import CommentsPage from './pages/CommentsPage';
|
||||||
import RatingsPage from './pages/RatingsPage';
|
import RatingsPage from './pages/RatingsPage';
|
||||||
import SmsPage from './pages/SmsPage';
|
import SmsPage from './pages/SmsPage';
|
||||||
@@ -81,6 +82,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Representations */}
|
{/* Representations */}
|
||||||
<Route path="representations" element={<RepresentationsPage />} />
|
<Route path="representations" element={<RepresentationsPage />} />
|
||||||
|
<Route path="representations/:uuid" element={<RepresentationDetailPage />} />
|
||||||
|
|
||||||
{/* Comments & Ratings */}
|
{/* Comments & Ratings */}
|
||||||
<Route path="comments" element={<CommentsPage />} />
|
<Route path="comments" element={<CommentsPage />} />
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const paymentMap: Record<PaymentStatus, { color: string; label: string }> = {
|
|||||||
|
|
||||||
const smsMap: Record<SmsTemplateStatus, { color: string; label: string }> = {
|
const smsMap: Record<SmsTemplateStatus, { color: string; label: string }> = {
|
||||||
draft: { color: 'gray', label: 'پیشنویس' },
|
draft: { color: 'gray', label: 'پیشنویس' },
|
||||||
pending_approval: { color: 'yellow', label: 'در انتظار تأیید' },
|
pending: { color: 'yellow', label: 'در انتظار تأیید' },
|
||||||
approved: { color: 'green', label: 'تأیید شده' },
|
approved: { color: 'green', label: 'تأیید شده' },
|
||||||
rejected: { color: 'red', label: 'رد شده' },
|
rejected: { color: 'red', label: 'رد شده' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ export interface ApiResponse<T> {
|
|||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: {
|
data: T[];
|
||||||
items: T[];
|
meta: {
|
||||||
total: number;
|
totalRecords: number;
|
||||||
page: number;
|
totalPages: number;
|
||||||
limit: number;
|
currentPage: number;
|
||||||
};
|
};
|
||||||
errors: [];
|
errors: [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function AppointmentsPage() {
|
|||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
if (statusFilter) params.set('status', statusFilter);
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
return api.get<PaginatedResponse<Appointment>>(`/api/v1/appointments?${params}`);
|
return api.get<PaginatedResponse<Appointment>>(`/api/v1/admin/appointments?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ export default function AppointmentsPage() {
|
|||||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (a) => formatDate(a.created_at) },
|
{ key: 'created_at', header: 'تاریخ ثبت', render: (a) => formatDate(a.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ export default function BlogsPage() {
|
|||||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (b) => formatDate(b.created_at) },
|
{ key: 'created_at', header: 'تاریخ ثبت', render: (b) => formatDate(b.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -16,17 +16,16 @@ import Modal from '../components/ui/Modal';
|
|||||||
const TABS: { key: CategoryBundle; label: string }[] = [
|
const TABS: { key: CategoryBundle; label: string }[] = [
|
||||||
{ key: 'state', label: 'استانها' },
|
{ key: 'state', label: 'استانها' },
|
||||||
{ key: 'city', label: 'شهرها' },
|
{ key: 'city', label: 'شهرها' },
|
||||||
{ key: 'specialty', label: 'تخصصها' },
|
{ key: 'specially_doctor', label: 'تخصصها' },
|
||||||
{ key: 'doctor_service', label: 'خدمات پزشک' },
|
{ key: 'doctor_services', label: 'خدمات پزشک' },
|
||||||
{ key: 'insurance_type', label: 'نوع بیمه' },
|
{ key: 'insurance_type', label: 'نوع بیمه' },
|
||||||
{ key: 'supplementary_insurance', label: 'بیمه تکمیلی' },
|
{ key: 'supplementary_insurance', label: 'بیمه تکمیلی' },
|
||||||
{ key: 'tag', label: 'تگهای بلاگ' },
|
{ key: 'tag', label: 'تگهای بلاگ' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, 'نام الزامی است'),
|
label: z.string().min(1, 'نام الزامی است'),
|
||||||
code: z.string().optional(),
|
parent_id: z.string().optional(),
|
||||||
parent_uuid: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ export default function CategoriesPage() {
|
|||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['categories', activeTab],
|
queryKey: ['categories', activeTab],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<ApiResponse<Category[]>>(`/api/v1/categorys/${activeTab}`),
|
api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${activeTab}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, reset, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
const { register, handleSubmit, reset, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||||
@@ -50,7 +49,11 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (d: FormData) =>
|
mutationFn: (d: FormData) =>
|
||||||
api.post<ApiResponse<Category>>('/api/v1/category', { ...d, bundle: activeTab }),
|
api.post<ApiResponse<Category>>('/api/v1/category', {
|
||||||
|
label: d.label,
|
||||||
|
bundle: activeTab,
|
||||||
|
...(d.parent_id ? { parent_id: parseInt(d.parent_id) } : {}),
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('دستهبندی اضافه شد');
|
toast.success('دستهبندی اضافه شد');
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
@@ -61,8 +64,8 @@ export default function CategoriesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ uuid, d }: { uuid: string; d: FormData }) =>
|
mutationFn: ({ id, d }: { id: number; d: FormData }) =>
|
||||||
api.patch<ApiResponse<Category>>(`/api/v1/category/${uuid}`, d),
|
api.patch<ApiResponse<Category>>(`/api/v1/category/${id}`, { label: d.label }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('دستهبندی بروزرسانی شد');
|
toast.success('دستهبندی بروزرسانی شد');
|
||||||
setEditTarget(null);
|
setEditTarget(null);
|
||||||
@@ -73,7 +76,7 @@ export default function CategoriesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (c: Category) => api.delete<ApiResponse<null>>(`/api/v1/category/${c.uuid}`),
|
mutationFn: (c: Category) => api.delete<ApiResponse<null>>(`/api/v1/category/${c.id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('دستهبندی حذف شد');
|
toast.success('دستهبندی حذف شد');
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -84,20 +87,34 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const openEdit = (c: Category) => {
|
const openEdit = (c: Category) => {
|
||||||
setEditTarget(c);
|
setEditTarget(c);
|
||||||
setValue('name', c.name);
|
setValue('label', c.label);
|
||||||
setValue('code', c.code ?? '');
|
|
||||||
setValue('parent_uuid', c.parent_uuid ?? '');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allItems = data?.data ?? [];
|
const allItems = data?.data?.data ?? [];
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? allItems.filter((c) => c.name.includes(search))
|
? allItems.filter((c) => c.label.includes(search))
|
||||||
: allItems;
|
: allItems;
|
||||||
|
|
||||||
const columns: Column<Category>[] = [
|
const columns: Column<Category>[] = [
|
||||||
{ key: 'name', header: 'نام', render: (c) => <span className="font-medium">{c.name}</span> },
|
{ key: 'label', header: 'نام', render: (c) => <span className="font-medium">{c.label}</span> },
|
||||||
{ key: 'code', header: 'کد', render: (c) => c.code ? <span dir="ltr" className="font-mono text-xs">{c.code}</span> : '—' },
|
{
|
||||||
{ key: 'parent_name', header: 'والد', render: (c) => c.parent_name ?? '—' },
|
key: 'bundle',
|
||||||
|
header: 'نوع',
|
||||||
|
render: (c) => (
|
||||||
|
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{c.bundle}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: (c) => (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
c.status === 1 ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{c.status === 1 ? 'فعال' : 'غیرفعال'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleTabChange = (tab: CategoryBundle) => {
|
const handleTabChange = (tab: CategoryBundle) => {
|
||||||
@@ -158,6 +175,7 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Modal */}
|
||||||
<Modal open={addOpen} title={`افزودن — ${TABS.find((t) => t.key === activeTab)?.label}`}
|
<Modal open={addOpen} title={`افزودن — ${TABS.find((t) => t.key === activeTab)?.label}`}
|
||||||
onClose={() => { setAddOpen(false); reset(); }}
|
onClose={() => { setAddOpen(false); reset(); }}
|
||||||
footer={
|
footer={
|
||||||
@@ -176,25 +194,21 @@ export default function CategoriesPage() {
|
|||||||
<form id="cat-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
<form id="cat-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">نام</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">نام</label>
|
||||||
<input {...register('name')}
|
<input {...register('label')}
|
||||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
{errors.label && <p className="text-red-500 text-xs mt-1">{errors.label.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{activeTab === 'city' && (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">کد (اختیاری)</label>
|
|
||||||
<input {...register('code')} dir="ltr"
|
|
||||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
|
||||||
</div>
|
|
||||||
{(activeTab === 'city') && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">استان (UUID)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">شناسه استان</label>
|
||||||
<input {...register('parent_uuid')} dir="ltr" placeholder="uuid استان"
|
<input {...register('parent_id')} dir="ltr" placeholder="ID عددی استان"
|
||||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
<Modal open={!!editTarget} title="ویرایش دستهبندی"
|
<Modal open={!!editTarget} title="ویرایش دستهبندی"
|
||||||
onClose={() => { setEditTarget(null); reset(); }}
|
onClose={() => { setEditTarget(null); reset(); }}
|
||||||
footer={
|
footer={
|
||||||
@@ -211,18 +225,13 @@ export default function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form id="edit-cat-form"
|
<form id="edit-cat-form"
|
||||||
onSubmit={handleSubmit((d) => editTarget && updateMutation.mutate({ uuid: editTarget.uuid, d }))}
|
onSubmit={handleSubmit((d) => editTarget && updateMutation.mutate({ id: editTarget.id, d }))}
|
||||||
className="space-y-4">
|
className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">نام</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">نام</label>
|
||||||
<input {...register('name')}
|
<input {...register('label')}
|
||||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
|
||||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">کد (اختیاری)</label>
|
|
||||||
<input {...register('code')} dir="ltr"
|
|
||||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
|
{errors.label && <p className="text-red-500 text-xs mt-1">{errors.label.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -230,7 +239,7 @@ export default function CategoriesPage() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="حذف دستهبندی"
|
title="حذف دستهبندی"
|
||||||
message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`}
|
message={`آیا از حذف "${deleteTarget?.label}" اطمینان دارید؟`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="حذف"
|
||||||
danger
|
danger
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ export default function ClinicsPage() {
|
|||||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (c) => formatDate(c.created_at) },
|
{ key: 'created_at', header: 'تاریخ ثبت', render: (c) => formatDate(c.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
{ value: '', label: 'همه' },
|
{ value: '', label: 'همه' },
|
||||||
{ value: 'false', label: 'در انتظار تأیید' },
|
{ value: 'pending', label: 'در انتظار تأیید' },
|
||||||
{ value: 'true', label: 'تأییدشده' },
|
{ value: 'approved', label: 'تأییدشده' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function CommentsPage() {
|
export default function CommentsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [approvedFilter, setApprovedFilter] = useState('false');
|
const [approvedFilter, setApprovedFilter] = useState('pending');
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Comment | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Comment | null>(null);
|
||||||
const limit = 15;
|
const limit = 15;
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@ export default function CommentsPage() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
if (approvedFilter !== '') params.set('is_approved', approvedFilter);
|
if (approvedFilter !== '') params.set('status', approvedFilter);
|
||||||
return api.get<PaginatedResponse<Comment>>(`/api/v1/comments?${params}`);
|
return api.get<PaginatedResponse<Comment>>(`/api/v1/admin/comments?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (c: Comment) => api.patch<ApiResponse<null>>(`/api/v1/comment/${c.uuid}/approve`, {}),
|
mutationFn: (c: Comment) => api.post<ApiResponse<null>>(`/api/v1/admin/comment/${c.uuid}/approve`, {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('نظر تأیید شد');
|
toast.success('نظر تأیید شد');
|
||||||
qc.invalidateQueries({ queryKey: ['comments'] });
|
qc.invalidateQueries({ queryKey: ['comments'] });
|
||||||
@@ -78,8 +78,8 @@ export default function CommentsPage() {
|
|||||||
{ key: 'created_at', header: 'تاریخ', render: (c) => formatDate(c.created_at) },
|
{ key: 'created_at', header: 'تاریخ', render: (c) => formatDate(c.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
@@ -7,34 +8,55 @@ import {
|
|||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
ArrowTrendingUpIcon,
|
ArrowPathIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import type { ApiResponse } from '../lib/api';
|
||||||
|
import { formatNumber, formatRial } from '../lib/utils';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
total_users: number;
|
||||||
|
active_doctors: number;
|
||||||
|
total_doctors: number;
|
||||||
|
total_clinics: number;
|
||||||
|
today_appointments: number;
|
||||||
|
total_appointments: number;
|
||||||
|
today_payments_count: number;
|
||||||
|
today_payments_amount: number;
|
||||||
|
total_payments_amount: number;
|
||||||
|
pending_comments: number;
|
||||||
|
pending_settlements: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
trend?: string;
|
sub?: string;
|
||||||
trendUp?: boolean;
|
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
iconBg: string;
|
iconBg: string;
|
||||||
iconColor: string;
|
iconColor: string;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, trend, trendUp, icon: Icon, iconBg, iconColor }: StatCardProps) {
|
function StatCard({ label, value, sub, icon: Icon, iconBg, iconColor, loading }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,.08)] p-6">
|
<div className="bg-white rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,.08)] p-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
||||||
<p className="text-3xl font-bold text-gray-900">{value}</p>
|
{loading ? (
|
||||||
{trend && (
|
<div className="h-8 w-24 bg-gray-100 rounded animate-pulse mt-1" />
|
||||||
<div className={`flex items-center gap-1 mt-2 text-xs font-medium ${trendUp ? 'text-emerald-600' : 'text-red-500'}`}>
|
) : (
|
||||||
<ArrowTrendingUpIcon className={`w-3.5 h-3.5 ${!trendUp && 'rotate-180'}`} />
|
<p className="text-2xl font-bold text-gray-900 truncate">{value}</p>
|
||||||
<span>{trend}</span>
|
)}
|
||||||
</div>
|
{sub && !loading && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1.5">{sub}</p>
|
||||||
|
)}
|
||||||
|
{sub && loading && (
|
||||||
|
<div className="h-3 w-32 bg-gray-100 rounded animate-pulse mt-2" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${iconBg}`}>
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center shrink-0 mr-3 ${iconBg}`}>
|
||||||
<Icon className={`w-6 h-6 ${iconColor}`} />
|
<Icon className={`w-6 h-6 ${iconColor}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,79 +64,106 @@ function StatCard({ label, value, trend, trendUp, icon: Icon, iconBg, iconColor
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats: StatCardProps[] = [
|
|
||||||
{
|
|
||||||
label: 'کل کاربران',
|
|
||||||
value: '۱۲,۴۸۴',
|
|
||||||
trend: '۸٪ نسبت به ماه قبل',
|
|
||||||
trendUp: true,
|
|
||||||
icon: UserGroupIcon,
|
|
||||||
iconBg: 'bg-violet-100',
|
|
||||||
iconColor: 'text-violet-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'پزشکان فعال',
|
|
||||||
value: '۱,۲۸۴',
|
|
||||||
trend: '۱۲٪ نسبت به ماه قبل',
|
|
||||||
trendUp: true,
|
|
||||||
icon: HeartIcon,
|
|
||||||
iconBg: 'bg-emerald-100',
|
|
||||||
iconColor: 'text-emerald-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'کلینیکها',
|
|
||||||
value: '۳۲۱',
|
|
||||||
trend: '۴٪ نسبت به ماه قبل',
|
|
||||||
trendUp: false,
|
|
||||||
icon: BuildingOffice2Icon,
|
|
||||||
iconBg: 'bg-blue-100',
|
|
||||||
iconColor: 'text-blue-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'نوبتهای امروز',
|
|
||||||
value: '۱۴۸',
|
|
||||||
trend: '۲۳٪ نسبت به دیروز',
|
|
||||||
trendUp: true,
|
|
||||||
icon: CalendarDaysIcon,
|
|
||||||
iconBg: 'bg-orange-100',
|
|
||||||
iconColor: 'text-orange-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'پرداختهای امروز',
|
|
||||||
value: '۴۸,۲۰۰,۰۰۰',
|
|
||||||
trend: '۱۵٪ نسبت به دیروز',
|
|
||||||
trendUp: true,
|
|
||||||
icon: CreditCardIcon,
|
|
||||||
iconBg: 'bg-pink-100',
|
|
||||||
iconColor: 'text-pink-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'نظرات در انتظار',
|
|
||||||
value: '۱۲',
|
|
||||||
icon: ChatBubbleLeftEllipsisIcon,
|
|
||||||
iconBg: 'bg-yellow-100',
|
|
||||||
iconColor: 'text-yellow-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'درخواست تسویه',
|
|
||||||
value: '۵',
|
|
||||||
icon: BanknotesIcon,
|
|
||||||
iconBg: 'bg-teal-100',
|
|
||||||
iconColor: 'text-teal-600',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: () => api.get<ApiResponse<DashboardStats>>('/api/v1/admin/dashboard/stats'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats: DashboardStats | undefined = (data?.data as any)?.data ?? data?.data;
|
||||||
|
|
||||||
|
const fn = (n: number | undefined) => (n !== undefined ? formatNumber(n) : '—');
|
||||||
|
|
||||||
|
const cards: StatCardProps[] = [
|
||||||
|
{
|
||||||
|
label: 'کل کاربران',
|
||||||
|
value: fn(stats?.total_users),
|
||||||
|
icon: UserGroupIcon,
|
||||||
|
iconBg: 'bg-violet-100',
|
||||||
|
iconColor: 'text-violet-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'پزشکان فعال',
|
||||||
|
value: fn(stats?.active_doctors),
|
||||||
|
sub: stats ? `از ${formatNumber(stats.total_doctors)} پزشک` : undefined,
|
||||||
|
icon: HeartIcon,
|
||||||
|
iconBg: 'bg-emerald-100',
|
||||||
|
iconColor: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'کلینیکها',
|
||||||
|
value: fn(stats?.total_clinics),
|
||||||
|
icon: BuildingOffice2Icon,
|
||||||
|
iconBg: 'bg-blue-100',
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'نوبتهای امروز',
|
||||||
|
value: fn(stats?.today_appointments),
|
||||||
|
sub: stats ? `مجموع: ${formatNumber(stats.total_appointments)} نوبت` : undefined,
|
||||||
|
icon: CalendarDaysIcon,
|
||||||
|
iconBg: 'bg-orange-100',
|
||||||
|
iconColor: 'text-orange-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'درآمد امروز',
|
||||||
|
value: stats?.today_payments_amount !== undefined ? formatRial(stats.today_payments_amount) : '—',
|
||||||
|
sub: stats ? `${formatNumber(stats.today_payments_count)} تراکنش` : undefined,
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
iconBg: 'bg-pink-100',
|
||||||
|
iconColor: 'text-pink-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'کل درآمد',
|
||||||
|
value: stats?.total_payments_amount !== undefined ? formatRial(stats.total_payments_amount) : '—',
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
iconBg: 'bg-indigo-100',
|
||||||
|
iconColor: 'text-indigo-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'نظرات در انتظار',
|
||||||
|
value: fn(stats?.pending_comments),
|
||||||
|
icon: ChatBubbleLeftEllipsisIcon,
|
||||||
|
iconBg: 'bg-yellow-100',
|
||||||
|
iconColor: 'text-yellow-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'درخواستهای تسویه',
|
||||||
|
value: fn(stats?.pending_settlements),
|
||||||
|
icon: BanknotesIcon,
|
||||||
|
iconBg: 'bg-teal-100',
|
||||||
|
iconColor: 'text-teal-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">داشبورد</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mt-1">خلاصه وضعیت سیستم</p>
|
<h1 className="text-2xl font-bold text-gray-900">داشبورد</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">خلاصه وضعیت سیستم</p>
|
||||||
|
</div>
|
||||||
|
{isError && (
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
تلاش مجدد
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-600">
|
||||||
|
خطا در دریافت اطلاعات — اطلاعات نمایش داده شده ممکن است بهروز نباشند.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
{stats.map((stat) => (
|
{cards.map((card) => (
|
||||||
<StatCard key={stat.label} {...stat} />
|
<StatCard key={card.label} {...card} loading={isLoading} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ export default function DoctorsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function PaymentsPage() {
|
|||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
if (statusFilter) params.set('status', statusFilter);
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
return api.get<PaginatedResponse<Payment>>(`/api/v1/payments?${params}`);
|
return api.get<PaginatedResponse<Payment>>(`/api/v1/admin/payments?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,8 +78,8 @@ export default function PaymentsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function RatingsPage() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
return api.get<PaginatedResponse<Rating>>(`/api/v1/rates?${params}`);
|
return api.get<PaginatedResponse<Rating>>(`/api/v1/admin/rates?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ export default function RatingsPage() {
|
|||||||
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
|
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import type { ApiResponse } from '../lib/api';
|
||||||
|
import type { Representation } from '../types';
|
||||||
|
import { formatDate, formatNumber } from '../lib/utils';
|
||||||
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import Modal from '../components/ui/Modal';
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-4 py-3 border-b border-gray-100 last:border-0">
|
||||||
|
<span className="text-sm text-gray-500 w-40 shrink-0">{label}</span>
|
||||||
|
<span className="text-sm text-gray-800 font-medium">{value ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RepresentationDetailPage() {
|
||||||
|
const { uuid } = useParams<{ uuid: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({ full_name: '', city: '', mobile_number: '', commission_percent: '' });
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['representation', uuid],
|
||||||
|
queryFn: () => api.get<ApiResponse<{ data: Representation }>>(`/api/v1/representation/${uuid}`),
|
||||||
|
enabled: !!uuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rep: Representation | undefined = (data?.data as any)?.data ?? data?.data;
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (d: Partial<Representation>) =>
|
||||||
|
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, d),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('اطلاعات بروزرسانی شد');
|
||||||
|
setEditOpen(false);
|
||||||
|
qc.invalidateQueries({ queryKey: ['representation', uuid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['representations'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActiveMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, {
|
||||||
|
active: !(rep?.active ?? rep?.is_active),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('وضعیت تغییر کرد');
|
||||||
|
qc.invalidateQueries({ queryKey: ['representation', uuid] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/representation/${uuid}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('نماینده حذف شد');
|
||||||
|
navigate('/admin/representations');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEdit = () => {
|
||||||
|
if (!rep) return;
|
||||||
|
setFormData({
|
||||||
|
full_name: rep.full_name ?? rep.domain ?? '',
|
||||||
|
city: rep.city ?? '',
|
||||||
|
mobile_number: rep.mobile_number ?? '',
|
||||||
|
commission_percent: String(rep.commission_percent),
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = rep?.active ?? rep?.is_active ?? false;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="جزئیات نماینده"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||||
|
{ label: 'نمایندگان', to: '/admin/representations' },
|
||||||
|
{ label: 'جزئیات' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 animate-pulse">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="h-8 bg-gray-100 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rep) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="نماینده یافت نشد"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||||
|
{ label: 'نمایندگان', to: '/admin/representations' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-12 text-center text-gray-400">
|
||||||
|
نمایندهای با این شناسه یافت نشد.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = rep.full_name ?? rep.domain ?? '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title={name}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||||
|
{ label: 'نمایندگان', to: '/admin/representations' },
|
||||||
|
{ label: name },
|
||||||
|
]}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActiveMutation.mutate()}
|
||||||
|
disabled={toggleActiveMutation.isPending}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border transition-colors disabled:opacity-50 ${
|
||||||
|
isActive
|
||||||
|
? 'border-red-300 text-red-600 hover:bg-red-50'
|
||||||
|
: 'border-green-300 text-green-600 hover:bg-green-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive
|
||||||
|
? <><XCircleIcon className="w-4 h-4" /> غیرفعال کردن</>
|
||||||
|
: <><CheckCircleIcon className="w-4 h-4" /> فعال کردن</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openEdit}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-red-300 text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 mb-4">اطلاعات پایه</h2>
|
||||||
|
<DetailRow label="نام کامل" value={rep.full_name} />
|
||||||
|
<DetailRow label="موبایل" value={
|
||||||
|
rep.mobile_number
|
||||||
|
? <span dir="ltr">{rep.mobile_number}</span>
|
||||||
|
: null
|
||||||
|
} />
|
||||||
|
<DetailRow label="شهر" value={rep.city} />
|
||||||
|
<DetailRow label="درصد کمیسیون" value={`${formatNumber(rep.commission_percent)}٪`} />
|
||||||
|
<DetailRow label="وضعیت" value={<ActiveBadge active={isActive} />} />
|
||||||
|
<DetailRow label="تاریخ ثبت" value={formatDate(rep.created_at)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bank Account */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 mb-4">اطلاعات بانکی</h2>
|
||||||
|
{rep.bank_account ? (
|
||||||
|
<>
|
||||||
|
<DetailRow label="شماره کارت" value={
|
||||||
|
rep.bank_account.card
|
||||||
|
? <span dir="ltr" className="font-mono tracking-widest">{rep.bank_account.card}</span>
|
||||||
|
: null
|
||||||
|
} />
|
||||||
|
<DetailRow label="نام بانک" value={rep.bank_account.bank_name} />
|
||||||
|
<DetailRow label="شماره شبا" value={
|
||||||
|
rep.bank_account.iban
|
||||||
|
? <span dir="ltr" className="font-mono text-xs">{rep.bank_account.iban}</span>
|
||||||
|
: null
|
||||||
|
} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 text-center py-6">اطلاعات بانکی ثبت نشده</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal open={editOpen} title="ویرایش نماینده"
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={() => setEditOpen(false)}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||||
|
لغو
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateMutation.mutate({
|
||||||
|
full_name: formData.full_name,
|
||||||
|
city: formData.city,
|
||||||
|
mobile_number: formData.mobile_number || null,
|
||||||
|
commission_percent: parseFloat(formData.commission_percent) || rep.commission_percent,
|
||||||
|
})}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors">
|
||||||
|
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">نام کامل</label>
|
||||||
|
<input value={formData.full_name} onChange={(e) => setFormData((p) => ({ ...p, full_name: e.target.value }))}
|
||||||
|
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">شهر</label>
|
||||||
|
<input value={formData.city} onChange={(e) => setFormData((p) => ({ ...p, city: e.target.value }))}
|
||||||
|
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">موبایل</label>
|
||||||
|
<input value={formData.mobile_number} onChange={(e) => setFormData((p) => ({ ...p, mobile_number: e.target.value }))}
|
||||||
|
dir="ltr" className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">درصد کمیسیون</label>
|
||||||
|
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: e.target.value }))}
|
||||||
|
type="number" min="0" max="100" dir="ltr"
|
||||||
|
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
title="حذف نماینده"
|
||||||
|
message={`آیا از حذف نماینده "${name}" اطمینان دارید؟`}
|
||||||
|
confirmLabel="حذف"
|
||||||
|
danger
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export default function RepresentationsPage() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
return api.get<PaginatedResponse<Representation>>(`/api/v1/representations?${params}`);
|
return api.get<PaginatedResponse<Representation>>(`/api/v1/admin/representations?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,14 +79,14 @@ export default function RepresentationsPage() {
|
|||||||
{
|
{
|
||||||
key: 'wallet_balance',
|
key: 'wallet_balance',
|
||||||
header: 'موجودی کیفپول',
|
header: 'موجودی کیفپول',
|
||||||
render: (r) => formatRial(r.wallet_balance),
|
render: (r) => formatRial(r.wallet_balance ?? 0),
|
||||||
},
|
},
|
||||||
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active} /> },
|
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active ?? r.active ?? false} /> },
|
||||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
|
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -134,13 +134,13 @@ export default function SecretariesPage() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
return api.get<PaginatedResponse<Secretary>>(`/api/v1/secretaries?${params}`);
|
return api.get<PaginatedResponse<Secretary>>(`/api/v1/admin/secretaries?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePermsMutation = useMutation({
|
const updatePermsMutation = useMutation({
|
||||||
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) =>
|
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) =>
|
||||||
api.patch<ApiResponse<null>>(`/api/v1/secretary/${uuid}/permissions`, { permissions }),
|
api.patch<ApiResponse<null>>(`/api/v1/secretary/${uuid}`, { permissions }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('دسترسیها بروزرسانی شد');
|
toast.success('دسترسیها بروزرسانی شد');
|
||||||
setEditTarget(null);
|
setEditTarget(null);
|
||||||
@@ -180,8 +180,8 @@ export default function SecretariesPage() {
|
|||||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
|
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ export default function SettlementsPage() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (statusFilter) params.set('status', statusFilter);
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
return api.get<PaginatedResponse<Settlement>>(`/api/v1/settlements?${params}`);
|
return api.get<PaginatedResponse<Settlement>>(`/api/v1/admin/settlements?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (s: Settlement) =>
|
mutationFn: (s: Settlement) =>
|
||||||
api.patch<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/approve`, {}),
|
api.post<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/approve`, {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('تسویه تأیید شد');
|
toast.success('تسویه تأیید شد');
|
||||||
setApproveTarget(null);
|
setApproveTarget(null);
|
||||||
@@ -53,7 +53,7 @@ export default function SettlementsPage() {
|
|||||||
|
|
||||||
const rejectMutation = useMutation({
|
const rejectMutation = useMutation({
|
||||||
mutationFn: ({ s, reason }: { s: Settlement; reason: string }) =>
|
mutationFn: ({ s, reason }: { s: Settlement; reason: string }) =>
|
||||||
api.patch<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/reject`, { reason }),
|
api.post<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/reject`, { reason }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('تسویه رد شد');
|
toast.success('تسویه رد شد');
|
||||||
setRejectTarget(null);
|
setRejectTarget(null);
|
||||||
@@ -72,8 +72,8 @@ export default function SettlementsPage() {
|
|||||||
{ key: 'requested_at', header: 'تاریخ درخواست', render: (s) => formatDate(s.requested_at) },
|
{ key: 'requested_at', header: 'تاریخ درخواست', render: (s) => formatDate(s.requested_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline';
|
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -18,8 +18,7 @@ import Modal from '../components/ui/Modal';
|
|||||||
|
|
||||||
const templateSchema = z.object({
|
const templateSchema = z.object({
|
||||||
name: z.string().min(2, 'نام قالب الزامی است'),
|
name: z.string().min(2, 'نام قالب الزامی است'),
|
||||||
category: z.string().min(1, 'دستهبندی الزامی است'),
|
body: z.string().min(5, 'متن قالب الزامی است'),
|
||||||
content: z.string().min(5, 'متن قالب الزامی است'),
|
|
||||||
});
|
});
|
||||||
type TemplateFormData = z.infer<typeof templateSchema>;
|
type TemplateFormData = z.infer<typeof templateSchema>;
|
||||||
|
|
||||||
@@ -38,7 +37,9 @@ export default function SmsPage() {
|
|||||||
const sampleTemplatesQuery = useQuery({
|
const sampleTemplatesQuery = useQuery({
|
||||||
queryKey: ['sms-samples', page],
|
queryKey: ['sms-samples', page],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<PaginatedResponse<SmsTemplate>>(`/api/v1/sms/sample-templates?page=${page}&limit=${limit}`),
|
api.get<PaginatedResponse<SmsTemplate>>(
|
||||||
|
`/api/v1/admin/sms/sample-templates?status=approved&page=${page}&limit=${limit}`,
|
||||||
|
),
|
||||||
enabled: activeTab === 'samples',
|
enabled: activeTab === 'samples',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ export default function SmsPage() {
|
|||||||
queryKey: ['sms-pending', page],
|
queryKey: ['sms-pending', page],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<PaginatedResponse<SmsTemplate>>(
|
api.get<PaginatedResponse<SmsTemplate>>(
|
||||||
`/api/v1/sms/templates?status=pending_approval&page=${page}&limit=${limit}`,
|
`/api/v1/admin/sms/sample-templates?status=pending&page=${page}&limit=${limit}`,
|
||||||
),
|
),
|
||||||
enabled: activeTab === 'pending',
|
enabled: activeTab === 'pending',
|
||||||
});
|
});
|
||||||
@@ -54,7 +55,7 @@ export default function SmsPage() {
|
|||||||
const logsQuery = useQuery({
|
const logsQuery = useQuery({
|
||||||
queryKey: ['sms-logs', page],
|
queryKey: ['sms-logs', page],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<PaginatedResponse<SmsLog>>(`/api/v1/sms/logs?page=${page}&limit=${limit}`),
|
api.get<PaginatedResponse<SmsLog>>(`/api/v1/admin/sms/logs?page=${page}&limit=${limit}`),
|
||||||
enabled: activeTab === 'logs',
|
enabled: activeTab === 'logs',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export default function SmsPage() {
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (d: TemplateFormData) =>
|
mutationFn: (d: TemplateFormData) =>
|
||||||
api.post<ApiResponse<SmsTemplate>>('/api/v1/sms/sample-template', d),
|
api.post<ApiResponse<SmsTemplate>>('/api/v1/sms/template', d),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('قالب اضافه شد');
|
toast.success('قالب اضافه شد');
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
@@ -76,7 +77,7 @@ export default function SmsPage() {
|
|||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (t: SmsTemplate) =>
|
mutationFn: (t: SmsTemplate) =>
|
||||||
api.patch<ApiResponse<null>>(`/api/v1/sms/templates/${t.uuid}/approve`, {}),
|
api.post<ApiResponse<null>>(`/api/v1/admin/sms/template/${t.uuid}/approve`, {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('قالب تأیید شد');
|
toast.success('قالب تأیید شد');
|
||||||
qc.invalidateQueries({ queryKey: ['sms-pending'] });
|
qc.invalidateQueries({ queryKey: ['sms-pending'] });
|
||||||
@@ -86,7 +87,7 @@ export default function SmsPage() {
|
|||||||
|
|
||||||
const rejectMutation = useMutation({
|
const rejectMutation = useMutation({
|
||||||
mutationFn: ({ t, reason }: { t: SmsTemplate; reason: string }) =>
|
mutationFn: ({ t, reason }: { t: SmsTemplate; reason: string }) =>
|
||||||
api.patch<ApiResponse<null>>(`/api/v1/sms/templates/${t.uuid}/reject`, { reason }),
|
api.post<ApiResponse<null>>(`/api/v1/admin/sms/template/${t.uuid}/reject`, { reason }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('قالب رد شد');
|
toast.success('قالب رد شد');
|
||||||
setRejectTarget(null);
|
setRejectTarget(null);
|
||||||
@@ -110,29 +111,23 @@ export default function SmsPage() {
|
|||||||
const templateColumns: Column<SmsTemplate>[] = [
|
const templateColumns: Column<SmsTemplate>[] = [
|
||||||
{ key: 'name', header: 'نام', render: (t) => <span className="font-medium">{t.name}</span> },
|
{ key: 'name', header: 'نام', render: (t) => <span className="font-medium">{t.name}</span> },
|
||||||
{
|
{
|
||||||
key: 'category',
|
key: 'body',
|
||||||
header: 'دستهبندی',
|
|
||||||
render: (t) => (
|
|
||||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{t.category}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'content',
|
|
||||||
header: 'محتوا',
|
header: 'محتوا',
|
||||||
render: (t) => <span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{t.content}</span>,
|
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: 'status', header: 'وضعیت', render: (t) => <StatusBadge type="sms" value={t.status} /> },
|
||||||
{ key: 'created_at', header: 'تاریخ', render: (t) => formatDate(t.created_at) },
|
{ key: 'created_at', header: 'تاریخ', render: (t) => formatDate(t.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const pendingColumns: Column<SmsTemplate>[] = [
|
const pendingColumns: Column<SmsTemplate>[] = templateColumns.filter((c) => c.key !== 'status');
|
||||||
...templateColumns.filter((c) => c.key !== 'status'),
|
|
||||||
{ key: 'owner_name', header: 'ارسالکننده' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const logColumns: Column<SmsLog>[] = [
|
const logColumns: Column<SmsLog>[] = [
|
||||||
{ key: 'recipient', header: 'گیرنده', render: (l) => <span dir="ltr">{l.recipient}</span> },
|
{ 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: 'message',
|
||||||
|
header: 'پیام',
|
||||||
|
render: (l) => <span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{l.message}</span>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'وضعیت',
|
header: 'وضعیت',
|
||||||
@@ -196,21 +191,19 @@ export default function SmsPage() {
|
|||||||
<>
|
<>
|
||||||
<DataTable<SmsTemplate>
|
<DataTable<SmsTemplate>
|
||||||
columns={templateColumns}
|
columns={templateColumns}
|
||||||
data={sampleTemplatesQuery.data?.data?.items ?? []}
|
data={sampleTemplatesQuery.data?.data ?? []}
|
||||||
loading={sampleTemplatesQuery.isLoading}
|
loading={sampleTemplatesQuery.isLoading}
|
||||||
emptyMessage="هیچ قالبی یافت نشد"
|
emptyMessage="هیچ قالبی یافت نشد"
|
||||||
actions={(t) => (
|
actions={(t) => (
|
||||||
<>
|
<button onClick={() => setDeleteTarget(t)}
|
||||||
<button onClick={() => setDeleteTarget(t)}
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
<TrashIcon className="w-4 h-4" />
|
||||||
<TrashIcon className="w-4 h-4" />
|
</button>
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
total={sampleTemplatesQuery.data?.data?.total ?? 0}
|
total={sampleTemplatesQuery.data?.meta?.totalRecords ?? 0}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
@@ -221,7 +214,7 @@ export default function SmsPage() {
|
|||||||
<>
|
<>
|
||||||
<DataTable<SmsTemplate>
|
<DataTable<SmsTemplate>
|
||||||
columns={pendingColumns}
|
columns={pendingColumns}
|
||||||
data={pendingTemplatesQuery.data?.data?.items ?? []}
|
data={pendingTemplatesQuery.data?.data ?? []}
|
||||||
loading={pendingTemplatesQuery.isLoading}
|
loading={pendingTemplatesQuery.isLoading}
|
||||||
emptyMessage="هیچ قالبی در انتظار تأیید نیست"
|
emptyMessage="هیچ قالبی در انتظار تأیید نیست"
|
||||||
actions={(t) => (
|
actions={(t) => (
|
||||||
@@ -239,7 +232,7 @@ export default function SmsPage() {
|
|||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
total={pendingTemplatesQuery.data?.data?.total ?? 0}
|
total={pendingTemplatesQuery.data?.meta?.totalRecords ?? 0}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
@@ -250,13 +243,13 @@ export default function SmsPage() {
|
|||||||
<>
|
<>
|
||||||
<DataTable<SmsLog>
|
<DataTable<SmsLog>
|
||||||
columns={logColumns}
|
columns={logColumns}
|
||||||
data={logsQuery.data?.data?.items ?? []}
|
data={logsQuery.data?.data ?? []}
|
||||||
loading={logsQuery.isLoading}
|
loading={logsQuery.isLoading}
|
||||||
emptyMessage="لاگی یافت نشد"
|
emptyMessage="لاگی یافت نشد"
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
total={logsQuery.data?.data?.total ?? 0}
|
total={logsQuery.data?.meta?.totalRecords ?? 0}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
@@ -286,17 +279,11 @@ export default function SmsPage() {
|
|||||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">دستهبندی</label>
|
|
||||||
<input {...register('category')} placeholder="مثال: appointment"
|
|
||||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
|
||||||
{errors.category && <p className="text-red-500 text-xs mt-1">{errors.category.message}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">متن قالب</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">متن قالب</label>
|
||||||
<textarea {...register('content')} rows={4} placeholder="متن پیامک..."
|
<textarea {...register('body')} rows={4} placeholder="متن پیامک..."
|
||||||
className="w-full border border-gray-300 rounded-[10px] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
|
className="w-full border border-gray-300 rounded-[10px] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
|
||||||
{errors.content && <p className="text-red-500 text-xs mt-1">{errors.content.message}</p>}
|
{errors.body && <p className="text-red-500 text-xs mt-1">{errors.body.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function UsersPage() {
|
|||||||
queryKey: ['users', page, search],
|
queryKey: ['users', page, search],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<PaginatedResponse<User>>(
|
api.get<PaginatedResponse<User>>(
|
||||||
`/api/v1/users?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
|
`/api/v1/admin/users?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,9 +45,7 @@ export default function UsersPage() {
|
|||||||
header: 'نام',
|
header: 'نام',
|
||||||
render: (u) => (
|
render: (u) => (
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900">
|
||||||
{u.first_name || u.last_name
|
{u.name || (u.first_name || u.last_name ? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim() : '—')}
|
||||||
? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim()
|
|
||||||
: '—'}
|
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -81,8 +79,8 @@ export default function UsersPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = data?.data?.items ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.data?.total ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -132,7 +130,7 @@ export default function UsersPage() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="حذف کاربر"
|
title="حذف کاربر"
|
||||||
message={`آیا از حذف کاربر "${deleteTarget?.first_name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
message={`آیا از حذف کاربر "${deleteTarget?.name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="حذف"
|
||||||
danger
|
danger
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
|
|||||||
+22
-19
@@ -2,13 +2,14 @@ export interface User {
|
|||||||
uuid: string;
|
uuid: string;
|
||||||
id: number;
|
id: number;
|
||||||
mobile_number: string;
|
mobile_number: string;
|
||||||
first_name: string | null;
|
name?: string | null;
|
||||||
last_name: string | null;
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
@@ -105,11 +106,15 @@ export interface Settlement {
|
|||||||
|
|
||||||
export interface Representation {
|
export interface Representation {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
domain: string;
|
full_name: string;
|
||||||
city: string;
|
domain?: string;
|
||||||
|
mobile_number: string | null;
|
||||||
|
city: string | null;
|
||||||
commission_percent: number;
|
commission_percent: number;
|
||||||
wallet_balance: number;
|
bank_account: { card?: string; bank_name?: string; iban?: string } | null;
|
||||||
is_active: boolean;
|
wallet_balance?: number;
|
||||||
|
active?: boolean;
|
||||||
|
is_active?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,16 +141,15 @@ export interface Rating {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SmsTemplateStatus = 'draft' | 'pending_approval' | 'approved' | 'rejected';
|
export type SmsTemplateStatus = 'draft' | 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
export interface SmsTemplate {
|
export interface SmsTemplate {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
body: string;
|
||||||
content: string;
|
provider_code: string | null;
|
||||||
status: SmsTemplateStatus;
|
status: SmsTemplateStatus;
|
||||||
owner_name: string | null;
|
admin_note: string | null;
|
||||||
reject_reason: string | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,20 +166,19 @@ export interface SmsLog {
|
|||||||
export type CategoryBundle =
|
export type CategoryBundle =
|
||||||
| 'state'
|
| 'state'
|
||||||
| 'city'
|
| 'city'
|
||||||
| 'specialty'
|
| 'specially_doctor'
|
||||||
| 'doctor_service'
|
| 'doctor_services'
|
||||||
| 'insurance_type'
|
| 'insurance_type'
|
||||||
| 'supplementary_insurance'
|
| 'supplementary_insurance'
|
||||||
| 'tag';
|
| 'tag';
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
|
id: number;
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
label: string;
|
||||||
code: string | null;
|
|
||||||
bundle: CategoryBundle;
|
bundle: CategoryBundle;
|
||||||
parent_uuid: string | null;
|
status: number;
|
||||||
parent_name: string | null;
|
weight: number;
|
||||||
sort_order: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Blog {
|
export interface Blog {
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Admin\Controller;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Clinic\Entity\Clinic;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Payment\Entity\Payment;
|
||||||
|
use App\Rating\Entity\Comment;
|
||||||
|
use App\Rating\Entity\Rate;
|
||||||
|
use App\Representation\Entity\Representation;
|
||||||
|
use App\Secretary\Entity\DoctorSecretary;
|
||||||
|
use App\Settlement\Entity\Settlement;
|
||||||
|
use App\Sms\Entity\SmsLog;
|
||||||
|
use App\Sms\Entity\SmsTemplate;
|
||||||
|
use App\Shared\Controller\BaseController;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
class AdminApiController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/users', methods: ['GET'])]
|
||||||
|
public function users(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$search = trim((string) $request->query->get('search', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select('u.uuid, u.id, u.mobileNumber as mobile, u.realName as name, u.email, u.roles, u.status, u.createdAt')
|
||||||
|
->from(User::class, 'u')
|
||||||
|
->orderBy('u.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s')
|
||||||
|
->setParameter('s', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(u.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $u) => [
|
||||||
|
'uuid' => $u['uuid'],
|
||||||
|
'id' => $u['id'],
|
||||||
|
'mobile_number' => $u['mobile'],
|
||||||
|
'name' => $u['name'],
|
||||||
|
'email' => $u['email'],
|
||||||
|
'roles' => $u['roles'],
|
||||||
|
'is_active' => $u['status'] === 1,
|
||||||
|
'created_at' => date('c', (int) $u['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Appointments ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/appointments', methods: ['GET'])]
|
||||||
|
public function appointments(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$search = trim((string) $request->query->get('search', ''));
|
||||||
|
$status = trim((string) $request->query->get('status', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select(
|
||||||
|
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
|
||||||
|
'd.uuid as doctor_uuid, d.name as doctor_name',
|
||||||
|
'u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||||
|
)
|
||||||
|
->from(Appointment::class, 'a')
|
||||||
|
->join('a.doctor', 'd')
|
||||||
|
->join('a.user', 'u')
|
||||||
|
->orderBy('a.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s')
|
||||||
|
->setParameter('s', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
if ($status !== '') {
|
||||||
|
$qb->andWhere('a.status = :status')->setParameter('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $a) => [
|
||||||
|
'uuid' => $a['uuid'],
|
||||||
|
'patient_name' => $a['patient_name'] ?? '',
|
||||||
|
'patient_mobile' => $a['patient_mobile'],
|
||||||
|
'doctor_name' => $a['doctor_name'],
|
||||||
|
'appointment_date' => date('Y-m-d', (int) $a['slotStart']),
|
||||||
|
'appointment_time' => date('H:i', (int) $a['slotStart']),
|
||||||
|
'status' => $a['status'],
|
||||||
|
'amount' => 0,
|
||||||
|
'created_at' => date('c', (int) $a['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payments ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/payments', methods: ['GET'])]
|
||||||
|
public function payments(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$search = trim((string) $request->query->get('search', ''));
|
||||||
|
$status = trim((string) $request->query->get('status', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select(
|
||||||
|
'p.uuid, p.amountRials, p.status, p.gateway, p.referenceId, p.createdAt, p.updatedAt',
|
||||||
|
'u.mobileNumber as patient_mobile',
|
||||||
|
)
|
||||||
|
->from(Payment::class, 'p')
|
||||||
|
->join('p.user', 'u')
|
||||||
|
->orderBy('p.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$qb->andWhere('u.mobileNumber LIKE :s OR p.referenceId LIKE :s OR p.orderId LIKE :s')
|
||||||
|
->setParameter('s', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
if ($status !== '') {
|
||||||
|
$qb->andWhere('p.status = :status')->setParameter('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $p) => [
|
||||||
|
'uuid' => $p['uuid'],
|
||||||
|
'amount' => (int) $p['amountRials'],
|
||||||
|
'status' => $p['status'],
|
||||||
|
'gateway' => $p['gateway'],
|
||||||
|
'ref_id' => $p['referenceId'],
|
||||||
|
'patient_mobile' => $p['patient_mobile'],
|
||||||
|
'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null,
|
||||||
|
'created_at' => date('c', (int) $p['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Representations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/representations', methods: ['GET'])]
|
||||||
|
public function representations(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$search = trim((string) $request->query->get('search', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select('r.uuid, r.fullName, r.mobileNumber, r.city, r.commissionPercent, r.active, r.createdAt')
|
||||||
|
->from(Representation::class, 'r')
|
||||||
|
->orderBy('r.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$qb->andWhere('r.fullName LIKE :s OR r.city LIKE :s')
|
||||||
|
->setParameter('s', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(r.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $r) => [
|
||||||
|
'uuid' => $r['uuid'],
|
||||||
|
'domain' => $r['fullName'],
|
||||||
|
'full_name' => $r['fullName'],
|
||||||
|
'mobile_number' => $r['mobileNumber'],
|
||||||
|
'city' => $r['city'] ?? '',
|
||||||
|
'commission_percent' => (float) $r['commissionPercent'],
|
||||||
|
'wallet_balance' => 0,
|
||||||
|
'is_active' => (bool) $r['active'],
|
||||||
|
'created_at' => date('c', (int) $r['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Secretaries ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/secretaries', methods: ['GET'])]
|
||||||
|
public function secretaries(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$search = trim((string) $request->query->get('search', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select(
|
||||||
|
'ds.uuid, ds.permissions, ds.active, ds.createdAt',
|
||||||
|
'u.mobileNumber as mobile, u.realName as user_name',
|
||||||
|
'd.name as doctor_name, d.uuid as doctor_uuid',
|
||||||
|
)
|
||||||
|
->from(DoctorSecretary::class, 'ds')
|
||||||
|
->join('ds.doctor', 'd')
|
||||||
|
->join('ds.secretary', 'u')
|
||||||
|
->orderBy('ds.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR d.name LIKE :s')
|
||||||
|
->setParameter('s', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(ds.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $ds) => [
|
||||||
|
'uuid' => $ds['uuid'],
|
||||||
|
'user_name' => $ds['user_name'] ?? $ds['mobile'],
|
||||||
|
'mobile_number' => $ds['mobile'],
|
||||||
|
'doctor_name' => $ds['doctor_name'],
|
||||||
|
'doctor_uuid' => $ds['doctor_uuid'],
|
||||||
|
'is_active' => (bool) $ds['active'],
|
||||||
|
'permissions' => $ds['permissions'] ?? DoctorSecretary::DEFAULT_PERMISSIONS,
|
||||||
|
'created_at' => date('c', (int) $ds['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ratings ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/rates', methods: ['GET'])]
|
||||||
|
public function rates(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$search = trim((string) $request->query->get('search', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select(
|
||||||
|
'r.uuid, r.score, r.createdAt',
|
||||||
|
'u.realName as patient_name, u.mobileNumber as patient_mobile',
|
||||||
|
'd.name as doctor_name',
|
||||||
|
)
|
||||||
|
->from(Rate::class, 'r')
|
||||||
|
->join('r.doctor', 'd')
|
||||||
|
->join('r.user', 'u')
|
||||||
|
->orderBy('r.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$qb->andWhere('d.name LIKE :s OR u.realName LIKE :s')
|
||||||
|
->setParameter('s', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(r.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $r) => [
|
||||||
|
'uuid' => $r['uuid'],
|
||||||
|
'patient_name' => $r['patient_name'] ?? $r['patient_mobile'],
|
||||||
|
'doctor_name' => $r['doctor_name'],
|
||||||
|
'overall' => (int) $r['score'],
|
||||||
|
'score' => (int) $r['score'],
|
||||||
|
'created_at' => date('c', (int) $r['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comments ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/comments', methods: ['GET'])]
|
||||||
|
public function comments(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$search = trim((string) $request->query->get('search', ''));
|
||||||
|
$status = trim((string) $request->query->get('status', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select(
|
||||||
|
'c.uuid, c.body, c.status, c.createdAt',
|
||||||
|
'u.realName as patient_name',
|
||||||
|
'd.name as doctor_name',
|
||||||
|
)
|
||||||
|
->from(Comment::class, 'c')
|
||||||
|
->join('c.doctor', 'd')
|
||||||
|
->join('c.user', 'u')
|
||||||
|
->orderBy('c.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$qb->andWhere('d.name LIKE :s OR c.body LIKE :s OR u.realName LIKE :s')
|
||||||
|
->setParameter('s', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
if ($status !== '') {
|
||||||
|
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $c) => [
|
||||||
|
'uuid' => $c['uuid'],
|
||||||
|
'patient_name' => $c['patient_name'] ?? '',
|
||||||
|
'doctor_name' => $c['doctor_name'],
|
||||||
|
'title' => mb_substr($c['body'], 0, 50),
|
||||||
|
'body' => $c['body'],
|
||||||
|
'is_approved' => $c['status'] === 'approved',
|
||||||
|
'status' => $c['status'],
|
||||||
|
'created_at' => date('c', (int) $c['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SMS Logs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/sms/logs', methods: ['GET'])]
|
||||||
|
public function smsLogs(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.createdAt')
|
||||||
|
->from(SmsLog::class, 's')
|
||||||
|
->orderBy('s.createdAt', 'DESC');
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $l) => [
|
||||||
|
'uuid' => $l['uuid'],
|
||||||
|
'recipient' => $l['mobile'],
|
||||||
|
'message' => $l['message'],
|
||||||
|
'status' => $l['success'] ? 'sent' : 'failed',
|
||||||
|
'provider' => $l['provider'],
|
||||||
|
'sent_at' => date('c', (int) $l['createdAt']),
|
||||||
|
'created_at' => date('c', (int) $l['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settlements ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/settlements', methods: ['GET'])]
|
||||||
|
public function settlements(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$status = trim((string) $request->query->get('status', ''));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select(
|
||||||
|
's.uuid, s.amountRials, s.status, s.bankAccount, s.adminNote, s.reviewedAt, s.createdAt',
|
||||||
|
'u.realName as user_name, u.mobileNumber as user_mobile',
|
||||||
|
)
|
||||||
|
->from(Settlement::class, 's')
|
||||||
|
->join('s.user', 'u')
|
||||||
|
->orderBy('s.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($status !== '') {
|
||||||
|
$qb->andWhere('s.status = :status')->setParameter('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $s) => [
|
||||||
|
'uuid' => $s['uuid'],
|
||||||
|
'representation_name' => $s['user_name'] ?? $s['user_mobile'],
|
||||||
|
'amount' => (int) $s['amountRials'],
|
||||||
|
'status' => $s['status'],
|
||||||
|
'bank_card' => $s['bankAccount']['card'] ?? null,
|
||||||
|
'bank_name' => $s['bankAccount']['bank_name'] ?? null,
|
||||||
|
'reject_reason' => $s['adminNote'],
|
||||||
|
'requested_at' => date('c', (int) $s['createdAt']),
|
||||||
|
'processed_at' => $s['reviewedAt'] ? date('c', (int) $s['reviewedAt']) : null,
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SMS Templates (paginated list with optional status filter) ────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/sms/sample-templates', methods: ['GET'])]
|
||||||
|
public function smsSampleTemplates(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
|
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||||
|
$status = trim((string) $request->query->get('status', 'approved'));
|
||||||
|
|
||||||
|
$qb = $this->em->createQueryBuilder()
|
||||||
|
->select('t.uuid, t.name, t.body, t.providerCode, t.status, t.adminNote, t.createdAt')
|
||||||
|
->from(SmsTemplate::class, 't')
|
||||||
|
->orderBy('t.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($status !== '') {
|
||||||
|
$qb->where('t.status = :status')->setParameter('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (clone $qb)->select('COUNT(t.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
$items = array_map(fn(array $t) => [
|
||||||
|
'uuid' => $t['uuid'],
|
||||||
|
'name' => $t['name'],
|
||||||
|
'body' => $t['body'],
|
||||||
|
'provider_code' => $t['providerCode'],
|
||||||
|
'status' => $t['status'],
|
||||||
|
'admin_note' => $t['adminNote'],
|
||||||
|
'created_at' => date('c', (int) $t['createdAt']),
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard Stats ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/dashboard/stats', methods: ['GET'])]
|
||||||
|
public function dashboardStats(): JsonResponse
|
||||||
|
{
|
||||||
|
$em = $this->em;
|
||||||
|
|
||||||
|
$todayStart = mktime(0, 0, 0);
|
||||||
|
$todayEnd = mktime(23, 59, 59);
|
||||||
|
|
||||||
|
$totalUsers = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(u.id)')->from(User::class, 'u')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$activeDoctors = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(d.id)')->from(Doctor::class, 'd')
|
||||||
|
->where('d.activeDoctorAppointment = true')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$totalDoctors = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(d.id)')->from(Doctor::class, 'd')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$totalClinics = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(c.id)')->from(Clinic::class, 'c')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$todayAppointments = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(a.id)')->from(Appointment::class, 'a')
|
||||||
|
->where('a.slotStart BETWEEN :s AND :e')
|
||||||
|
->setParameter('s', $todayStart)->setParameter('e', $todayEnd)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$totalAppointments = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(a.id)')->from(Appointment::class, 'a')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$todayPaymentsResult = $em->createQueryBuilder()
|
||||||
|
->select('COUNT(p.id) as cnt, COALESCE(SUM(p.amountRials), 0) as total')
|
||||||
|
->from(Payment::class, 'p')
|
||||||
|
->where('p.status = :s AND p.createdAt BETWEEN :ts AND :te')
|
||||||
|
->setParameter('s', Payment::STATUS_SUCCESS)
|
||||||
|
->setParameter('ts', $todayStart)->setParameter('te', $todayEnd)
|
||||||
|
->getQuery()->getSingleResult();
|
||||||
|
|
||||||
|
$totalPaymentsResult = $em->createQueryBuilder()
|
||||||
|
->select('COUNT(p.id) as cnt, COALESCE(SUM(p.amountRials), 0) as total')
|
||||||
|
->from(Payment::class, 'p')
|
||||||
|
->where('p.status = :s')
|
||||||
|
->setParameter('s', Payment::STATUS_SUCCESS)
|
||||||
|
->getQuery()->getSingleResult();
|
||||||
|
|
||||||
|
$pendingComments = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(c.id)')->from(Comment::class, 'c')
|
||||||
|
->where('c.status = :s')->setParameter('s', Comment::STATUS_PENDING)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$pendingSettlements = (int) $em->createQueryBuilder()
|
||||||
|
->select('COUNT(s.id)')->from(Settlement::class, 's')
|
||||||
|
->where('s.status = :s')->setParameter('s', Settlement::STATUS_PENDING)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'total_users' => $totalUsers,
|
||||||
|
'active_doctors' => $activeDoctors,
|
||||||
|
'total_doctors' => $totalDoctors,
|
||||||
|
'total_clinics' => $totalClinics,
|
||||||
|
'today_appointments' => $todayAppointments,
|
||||||
|
'total_appointments' => $totalAppointments,
|
||||||
|
'today_payments_count' => (int) $todayPaymentsResult['cnt'],
|
||||||
|
'today_payments_amount' => (int) $todayPaymentsResult['total'],
|
||||||
|
'total_payments_amount' => (int) $totalPaymentsResult['total'],
|
||||||
|
'pending_comments' => $pendingComments,
|
||||||
|
'pending_settlements' => $pendingSettlements,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user