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:
hamed
2026-06-09 23:41:44 +03:30
parent f619449167
commit 147a2a894e
20 changed files with 1067 additions and 228 deletions
+2
View File
@@ -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 />} />
+1 -1
View File
@@ -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: 'رد شده' },
};
+5 -5
View File
@@ -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: [];
}
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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>
+46 -37
View File
@@ -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}
+2 -2
View File
@@ -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>
+8 -8
View File
@@ -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>
+128 -79
View File
@@ -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>
+2 -2
View File
@@ -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>
+3 -3
View File
@@ -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>
+3 -3
View File
@@ -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>
);
}
+5 -5
View File
@@ -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>
+4 -4
View File
@@ -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>
+5 -5
View File
@@ -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>
+30 -43
View File
@@ -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>
+5 -7
View File
@@ -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
View File
@@ -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 {
+522
View File
@@ -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,
]);
}
}