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 SettlementsPage from './pages/SettlementsPage';
|
||||
import RepresentationsPage from './pages/RepresentationsPage';
|
||||
import RepresentationDetailPage from './pages/RepresentationDetailPage';
|
||||
import CommentsPage from './pages/CommentsPage';
|
||||
import RatingsPage from './pages/RatingsPage';
|
||||
import SmsPage from './pages/SmsPage';
|
||||
@@ -81,6 +82,7 @@ export default function App() {
|
||||
|
||||
{/* Representations */}
|
||||
<Route path="representations" element={<RepresentationsPage />} />
|
||||
<Route path="representations/:uuid" element={<RepresentationDetailPage />} />
|
||||
|
||||
{/* Comments & Ratings */}
|
||||
<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 }> = {
|
||||
draft: { color: 'gray', label: 'پیشنویس' },
|
||||
pending_approval: { color: 'yellow', label: 'در انتظار تأیید' },
|
||||
pending: { color: 'yellow', label: 'در انتظار تأیید' },
|
||||
approved: { color: 'green', label: 'تأیید شده' },
|
||||
rejected: { color: 'red', label: 'رد شده' },
|
||||
};
|
||||
|
||||
@@ -66,11 +66,11 @@ export interface ApiResponse<T> {
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean;
|
||||
data: {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
data: T[];
|
||||
meta: {
|
||||
totalRecords: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
};
|
||||
errors: [];
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function AppointmentsPage() {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
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) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -92,8 +92,8 @@ export default function BlogsPage() {
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (b) => formatDate(b.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -16,17 +16,16 @@ import Modal from '../components/ui/Modal';
|
||||
const TABS: { key: CategoryBundle; label: string }[] = [
|
||||
{ key: 'state', label: 'استانها' },
|
||||
{ key: 'city', label: 'شهرها' },
|
||||
{ key: 'specialty', label: 'تخصصها' },
|
||||
{ key: 'doctor_service', label: 'خدمات پزشک' },
|
||||
{ key: 'specially_doctor', label: 'تخصصها' },
|
||||
{ key: 'doctor_services', label: 'خدمات پزشک' },
|
||||
{ key: 'insurance_type', label: 'نوع بیمه' },
|
||||
{ key: 'supplementary_insurance', label: 'بیمه تکمیلی' },
|
||||
{ key: 'tag', label: 'تگهای بلاگ' },
|
||||
];
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
code: z.string().optional(),
|
||||
parent_uuid: z.string().optional(),
|
||||
label: z.string().min(1, 'نام الزامی است'),
|
||||
parent_id: z.string().optional(),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
@@ -41,7 +40,7 @@ export default function CategoriesPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['categories', activeTab],
|
||||
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>({
|
||||
@@ -50,7 +49,11 @@ export default function CategoriesPage() {
|
||||
|
||||
const createMutation = useMutation({
|
||||
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: () => {
|
||||
toast.success('دستهبندی اضافه شد');
|
||||
setAddOpen(false);
|
||||
@@ -61,8 +64,8 @@ export default function CategoriesPage() {
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, d }: { uuid: string; d: FormData }) =>
|
||||
api.patch<ApiResponse<Category>>(`/api/v1/category/${uuid}`, d),
|
||||
mutationFn: ({ id, d }: { id: number; d: FormData }) =>
|
||||
api.patch<ApiResponse<Category>>(`/api/v1/category/${id}`, { label: d.label }),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی بروزرسانی شد');
|
||||
setEditTarget(null);
|
||||
@@ -73,7 +76,7 @@ export default function CategoriesPage() {
|
||||
});
|
||||
|
||||
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: () => {
|
||||
toast.success('دستهبندی حذف شد');
|
||||
setDeleteTarget(null);
|
||||
@@ -84,20 +87,34 @@ export default function CategoriesPage() {
|
||||
|
||||
const openEdit = (c: Category) => {
|
||||
setEditTarget(c);
|
||||
setValue('name', c.name);
|
||||
setValue('code', c.code ?? '');
|
||||
setValue('parent_uuid', c.parent_uuid ?? '');
|
||||
setValue('label', c.label);
|
||||
};
|
||||
|
||||
const allItems = data?.data ?? [];
|
||||
const allItems = data?.data?.data ?? [];
|
||||
const filtered = search
|
||||
? allItems.filter((c) => c.name.includes(search))
|
||||
? allItems.filter((c) => c.label.includes(search))
|
||||
: allItems;
|
||||
|
||||
const columns: Column<Category>[] = [
|
||||
{ key: 'name', header: 'نام', render: (c) => <span className="font-medium">{c.name}</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: 'label', header: 'نام', render: (c) => <span className="font-medium">{c.label}</span> },
|
||||
{
|
||||
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) => {
|
||||
@@ -158,6 +175,7 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal open={addOpen} title={`افزودن — ${TABS.find((t) => t.key === activeTab)?.label}`}
|
||||
onClose={() => { setAddOpen(false); reset(); }}
|
||||
footer={
|
||||
@@ -176,25 +194,21 @@ export default function CategoriesPage() {
|
||||
<form id="cat-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||
<div>
|
||||
<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>}
|
||||
{errors.label && <p className="text-red-500 text-xs mt-1">{errors.label.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" />
|
||||
</div>
|
||||
{(activeTab === 'city') && (
|
||||
{activeTab === 'city' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">استان (UUID)</label>
|
||||
<input {...register('parent_uuid')} dir="ltr" placeholder="uuid استان"
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">شناسه استان</label>
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal open={!!editTarget} title="ویرایش دستهبندی"
|
||||
onClose={() => { setEditTarget(null); reset(); }}
|
||||
footer={
|
||||
@@ -211,18 +225,13 @@ export default function CategoriesPage() {
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نام</label>
|
||||
<input {...register('name')}
|
||||
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"
|
||||
<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.label && <p className="text-red-500 text-xs mt-1">{errors.label.message}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -230,7 +239,7 @@ export default function CategoriesPage() {
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف دستهبندی"
|
||||
message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`}
|
||||
message={`آیا از حذف "${deleteTarget?.label}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
|
||||
@@ -70,8 +70,8 @@ export default function ClinicsPage() {
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (c) => formatDate(c.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -14,15 +14,15 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const FILTERS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'false', label: 'در انتظار تأیید' },
|
||||
{ value: 'true', label: 'تأییدشده' },
|
||||
{ value: 'pending', label: 'در انتظار تأیید' },
|
||||
{ value: 'approved', label: 'تأییدشده' },
|
||||
];
|
||||
|
||||
export default function CommentsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [approvedFilter, setApprovedFilter] = useState('false');
|
||||
const [approvedFilter, setApprovedFilter] = useState('pending');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Comment | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
@@ -31,13 +31,13 @@ export default function CommentsPage() {
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (approvedFilter !== '') params.set('is_approved', approvedFilter);
|
||||
return api.get<PaginatedResponse<Comment>>(`/api/v1/comments?${params}`);
|
||||
if (approvedFilter !== '') params.set('status', approvedFilter);
|
||||
return api.get<PaginatedResponse<Comment>>(`/api/v1/admin/comments?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
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: () => {
|
||||
toast.success('نظر تأیید شد');
|
||||
qc.invalidateQueries({ queryKey: ['comments'] });
|
||||
@@ -78,8 +78,8 @@ export default function CommentsPage() {
|
||||
{ key: 'created_at', header: 'تاریخ', render: (c) => formatDate(c.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
UserGroupIcon,
|
||||
HeartIcon,
|
||||
@@ -7,34 +8,55 @@ import {
|
||||
CreditCardIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
BanknotesIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowPathIcon,
|
||||
} 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 {
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: string;
|
||||
trendUp?: boolean;
|
||||
sub?: string;
|
||||
icon: React.ElementType;
|
||||
iconBg: 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 (
|
||||
<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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{value}</p>
|
||||
{trend && (
|
||||
<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'}`} />
|
||||
<span>{trend}</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="h-8 w-24 bg-gray-100 rounded animate-pulse mt-1" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-gray-900 truncate">{value}</p>
|
||||
)}
|
||||
{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 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}`} />
|
||||
</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() {
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">داشبورد</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">خلاصه وضعیت سیستم</p>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{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">
|
||||
{stats.map((stat) => (
|
||||
<StatCard key={stat.label} {...stat} />
|
||||
{cards.map((card) => (
|
||||
<StatCard key={card.label} {...card} loading={isLoading} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -93,8 +93,8 @@ export default function DoctorsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function PaymentsPage() {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
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 total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function RatingsPage() {
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
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) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<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: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
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',
|
||||
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) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -134,13 +134,13 @@ export default function SecretariesPage() {
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
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({
|
||||
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: () => {
|
||||
toast.success('دسترسیها بروزرسانی شد');
|
||||
setEditTarget(null);
|
||||
@@ -180,8 +180,8 @@ export default function SecretariesPage() {
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -36,13 +36,13 @@ export default function SettlementsPage() {
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
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({
|
||||
mutationFn: (s: Settlement) =>
|
||||
api.patch<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/approve`, {}),
|
||||
api.post<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('تسویه تأیید شد');
|
||||
setApproveTarget(null);
|
||||
@@ -53,7 +53,7 @@ export default function SettlementsPage() {
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
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: () => {
|
||||
toast.success('تسویه رد شد');
|
||||
setRejectTarget(null);
|
||||
@@ -72,8 +72,8 @@ export default function SettlementsPage() {
|
||||
{ key: 'requested_at', header: 'تاریخ درخواست', render: (s) => formatDate(s.requested_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { 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';
|
||||
@@ -18,8 +18,7 @@ import Modal from '../components/ui/Modal';
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().min(2, 'نام قالب الزامی است'),
|
||||
category: z.string().min(1, 'دستهبندی الزامی است'),
|
||||
content: z.string().min(5, 'متن قالب الزامی است'),
|
||||
body: z.string().min(5, 'متن قالب الزامی است'),
|
||||
});
|
||||
type TemplateFormData = z.infer<typeof templateSchema>;
|
||||
|
||||
@@ -38,7 +37,9 @@ export default function SmsPage() {
|
||||
const sampleTemplatesQuery = useQuery({
|
||||
queryKey: ['sms-samples', page],
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -46,7 +47,7 @@ export default function SmsPage() {
|
||||
queryKey: ['sms-pending', page],
|
||||
queryFn: () =>
|
||||
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',
|
||||
});
|
||||
@@ -54,7 +55,7 @@ export default function SmsPage() {
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['sms-logs', page],
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -64,7 +65,7 @@ export default function SmsPage() {
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: TemplateFormData) =>
|
||||
api.post<ApiResponse<SmsTemplate>>('/api/v1/sms/sample-template', d),
|
||||
api.post<ApiResponse<SmsTemplate>>('/api/v1/sms/template', d),
|
||||
onSuccess: () => {
|
||||
toast.success('قالب اضافه شد');
|
||||
setAddOpen(false);
|
||||
@@ -76,7 +77,7 @@ export default function SmsPage() {
|
||||
|
||||
const approveMutation = useMutation({
|
||||
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: () => {
|
||||
toast.success('قالب تأیید شد');
|
||||
qc.invalidateQueries({ queryKey: ['sms-pending'] });
|
||||
@@ -86,7 +87,7 @@ export default function SmsPage() {
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
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: () => {
|
||||
toast.success('قالب رد شد');
|
||||
setRejectTarget(null);
|
||||
@@ -110,29 +111,23 @@ export default function SmsPage() {
|
||||
const templateColumns: Column<SmsTemplate>[] = [
|
||||
{ key: 'name', header: 'نام', render: (t) => <span className="font-medium">{t.name}</span> },
|
||||
{
|
||||
key: 'category',
|
||||
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',
|
||||
key: 'body',
|
||||
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: 'created_at', header: 'تاریخ', render: (t) => formatDate(t.created_at) },
|
||||
];
|
||||
|
||||
const pendingColumns: Column<SmsTemplate>[] = [
|
||||
...templateColumns.filter((c) => c.key !== 'status'),
|
||||
{ key: 'owner_name', header: 'ارسالکننده' },
|
||||
];
|
||||
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: 'message',
|
||||
header: 'پیام',
|
||||
render: (l) => <span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{l.message}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
@@ -196,21 +191,19 @@ export default function SmsPage() {
|
||||
<>
|
||||
<DataTable<SmsTemplate>
|
||||
columns={templateColumns}
|
||||
data={sampleTemplatesQuery.data?.data?.items ?? []}
|
||||
data={sampleTemplatesQuery.data?.data ?? []}
|
||||
loading={sampleTemplatesQuery.isLoading}
|
||||
emptyMessage="هیچ قالبی یافت نشد"
|
||||
actions={(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="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
<button onClick={() => setDeleteTarget(t)}
|
||||
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" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={sampleTemplatesQuery.data?.data?.total ?? 0}
|
||||
total={sampleTemplatesQuery.data?.meta?.totalRecords ?? 0}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
@@ -221,7 +214,7 @@ export default function SmsPage() {
|
||||
<>
|
||||
<DataTable<SmsTemplate>
|
||||
columns={pendingColumns}
|
||||
data={pendingTemplatesQuery.data?.data?.items ?? []}
|
||||
data={pendingTemplatesQuery.data?.data ?? []}
|
||||
loading={pendingTemplatesQuery.isLoading}
|
||||
emptyMessage="هیچ قالبی در انتظار تأیید نیست"
|
||||
actions={(t) => (
|
||||
@@ -239,7 +232,7 @@ export default function SmsPage() {
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={pendingTemplatesQuery.data?.data?.total ?? 0}
|
||||
total={pendingTemplatesQuery.data?.meta?.totalRecords ?? 0}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
@@ -250,13 +243,13 @@ export default function SmsPage() {
|
||||
<>
|
||||
<DataTable<SmsLog>
|
||||
columns={logColumns}
|
||||
data={logsQuery.data?.data?.items ?? []}
|
||||
data={logsQuery.data?.data ?? []}
|
||||
loading={logsQuery.isLoading}
|
||||
emptyMessage="لاگی یافت نشد"
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={logsQuery.data?.data?.total ?? 0}
|
||||
total={logsQuery.data?.meta?.totalRecords ?? 0}
|
||||
limit={limit}
|
||||
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" />
|
||||
{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('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>
|
||||
<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" />
|
||||
{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>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function UsersPage() {
|
||||
queryKey: ['users', page, search],
|
||||
queryFn: () =>
|
||||
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: 'نام',
|
||||
render: (u) => (
|
||||
<span className="font-medium text-gray-900">
|
||||
{u.first_name || u.last_name
|
||||
? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim()
|
||||
: '—'}
|
||||
{u.name || (u.first_name || u.last_name ? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim() : '—')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -81,8 +79,8 @@ export default function UsersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -132,7 +130,7 @@ export default function UsersPage() {
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف کاربر"
|
||||
message={`آیا از حذف کاربر "${deleteTarget?.first_name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||
message={`آیا از حذف کاربر "${deleteTarget?.name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
|
||||
+22
-19
@@ -2,13 +2,14 @@ export interface User {
|
||||
uuid: string;
|
||||
id: number;
|
||||
mobile_number: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
name?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
email: string | null;
|
||||
roles: string[];
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
@@ -105,11 +106,15 @@ export interface Settlement {
|
||||
|
||||
export interface Representation {
|
||||
uuid: string;
|
||||
domain: string;
|
||||
city: string;
|
||||
full_name: string;
|
||||
domain?: string;
|
||||
mobile_number: string | null;
|
||||
city: string | null;
|
||||
commission_percent: number;
|
||||
wallet_balance: number;
|
||||
is_active: boolean;
|
||||
bank_account: { card?: string; bank_name?: string; iban?: string } | null;
|
||||
wallet_balance?: number;
|
||||
active?: boolean;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -136,16 +141,15 @@ export interface Rating {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type SmsTemplateStatus = 'draft' | 'pending_approval' | 'approved' | 'rejected';
|
||||
export type SmsTemplateStatus = 'draft' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface SmsTemplate {
|
||||
uuid: string;
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
body: string;
|
||||
provider_code: string | null;
|
||||
status: SmsTemplateStatus;
|
||||
owner_name: string | null;
|
||||
reject_reason: string | null;
|
||||
admin_note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -162,20 +166,19 @@ export interface SmsLog {
|
||||
export type CategoryBundle =
|
||||
| 'state'
|
||||
| 'city'
|
||||
| 'specialty'
|
||||
| 'doctor_service'
|
||||
| 'specially_doctor'
|
||||
| 'doctor_services'
|
||||
| 'insurance_type'
|
||||
| 'supplementary_insurance'
|
||||
| 'tag';
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
label: string;
|
||||
bundle: CategoryBundle;
|
||||
parent_uuid: string | null;
|
||||
parent_name: string | null;
|
||||
sort_order: number;
|
||||
status: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
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