feat(admin): add user and doctor management APIs and frontend form
- Updated security configuration to include new API route for file uploads. - Added new endpoints in AdminApiController for user statistics, toggling user status, updating user roles, and managing user details. - Implemented doctor statistics and management endpoints, including toggling doctor status and creating new doctors. - Enhanced user listing with filtering options for roles and status. - Introduced DoctorFormPage component for adding new doctors with specialties selection. - Integrated react-leaflet for mapping functionalities and added necessary dependencies. - Updated package.json and package-lock.json to include new dependencies.
This commit is contained in:
@@ -8,6 +8,7 @@ import UsersPage from './pages/UsersPage';
|
||||
import UserDetailPage from './pages/UserDetailPage';
|
||||
import DoctorsPage from './pages/DoctorsPage';
|
||||
import DoctorDetailPage from './pages/DoctorDetailPage';
|
||||
import DoctorFormPage from './pages/DoctorFormPage';
|
||||
import ClinicsPage from './pages/ClinicsPage';
|
||||
import ClinicDetailPage from './pages/ClinicDetailPage';
|
||||
import AppointmentsPage from './pages/AppointmentsPage';
|
||||
@@ -63,6 +64,7 @@ export default function App() {
|
||||
|
||||
{/* Doctors */}
|
||||
<Route path="doctors" element={<DoctorsPage />} />
|
||||
<Route path="doctors/new" element={<DoctorFormPage />} />
|
||||
<Route path="doctors/:uuid" element={<DoctorDetailPage />} />
|
||||
|
||||
{/* Clinics */}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const BASE_URL = '';
|
||||
|
||||
function getToken(): string | null {
|
||||
@@ -35,6 +37,11 @@ async function request<T>(
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.replace('/admin/login');
|
||||
throw new ApiError(401, 'ERR_UNAUTHORIZED', 'نشست منقضی شده است');
|
||||
}
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const firstErr = body?.errors?.[0];
|
||||
throw new ApiError(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowRightIcon, UserPlusIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SpecialtyOption { id: number; uuid: string; name: string }
|
||||
|
||||
// ── Schema ───────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
mobile: z.string().min(10, 'شماره موبایل معتبر نیست').max(15),
|
||||
name: z.string().min(2, 'نام حداقل ۲ کاراکتر'),
|
||||
gender: z.enum(['man', 'woman']).optional().or(z.literal('')),
|
||||
degree: z.string().optional().or(z.literal('')),
|
||||
medical_system_code: z.string().max(30).optional().or(z.literal('')),
|
||||
info: z.string().max(2000).optional().or(z.literal('')),
|
||||
specialties: z.array(z.number()).optional(),
|
||||
});
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEGREE_OPTIONS = [
|
||||
{ value: 'general', label: 'عمومی' },
|
||||
{ value: 'specialist', label: 'متخصص' },
|
||||
{ value: 'expert', label: 'فوق تخصص' },
|
||||
{ value: 'subspecialistplus', label: 'فلوشیپ' },
|
||||
];
|
||||
|
||||
const GENDER_OPTIONS = [
|
||||
{ value: 'man', label: 'مرد' },
|
||||
{ value: 'woman', label: 'زن' },
|
||||
];
|
||||
|
||||
// ── Specialty Picker ──────────────────────────────────────────────────────
|
||||
|
||||
function SpecialtyPicker({ selected, onChange, specialties }: {
|
||||
selected: number[]; onChange: (ids: number[]) => void;
|
||||
specialties: SpecialtyOption[];
|
||||
}) {
|
||||
const [q, setQ] = useState('');
|
||||
const filtered = useMemo(() => specialties.filter(s => s.name.includes(q)), [specialties, q]);
|
||||
|
||||
const toggle = (id: number) =>
|
||||
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60">
|
||||
<input type="text" value={q} onChange={e => setQ(e.target.value)}
|
||||
placeholder="جستجوی تخصص..."
|
||||
className="cp-input text-sm h-8" />
|
||||
</div>
|
||||
{selected.length > 0 && (
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1">
|
||||
{selected.map(id => {
|
||||
const s = specialties.find(x => x.id === id);
|
||||
if (!s) return null;
|
||||
return (
|
||||
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
|
||||
{s.name}
|
||||
<button type="button" onClick={() => toggle(id)} className="hover:text-primary-900 dark:hover:text-primary-100 leading-none">×</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
{filtered.slice(0, 60).map(s => (
|
||||
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
|
||||
<input type="checkbox" checked={selected.includes(s.id)} onChange={() => toggle(s.id)}
|
||||
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{filtered.length === 0 && <p className="text-xs text-slate-400 text-center py-4">نتیجهای یافت نشد</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DoctorFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]);
|
||||
|
||||
const specialtiesQ = useQuery({
|
||||
queryKey: ['specialties-list'],
|
||||
queryFn: () => api.get<ApiResponse<SpecialtyOption[]>>('/api/v1/specialties'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
const specialties: SpecialtyOption[] = useMemo(
|
||||
() => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [],
|
||||
[specialtiesQ.data]
|
||||
);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { specialties: [] },
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/doctors', {
|
||||
mobile: values.mobile,
|
||||
name: values.name,
|
||||
gender: values.gender || undefined,
|
||||
degree: values.degree || undefined,
|
||||
medical_system_code: values.medical_system_code || undefined,
|
||||
info: values.info || undefined,
|
||||
specialties: selectedSpecialties,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
const uuid = (res?.data as any)?.uuid ?? res?.data?.uuid;
|
||||
toast.success('پزشک با موفقیت اضافه شد');
|
||||
if (uuid) navigate(`/admin/doctors/${uuid}`);
|
||||
else navigate('/admin/doctors');
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => createMut.mutate({ ...values, specialties: selectedSpecialties });
|
||||
|
||||
return (
|
||||
<div className="animate-slide-up max-w-3xl mx-auto space-y-5">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<button onClick={() => navigate('/admin/doctors')}
|
||||
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
|
||||
<ArrowRightIcon className="w-4 h-4" />پزشکان
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium">افزودن پزشک جدید</span>
|
||||
</div>
|
||||
|
||||
{/* Header card */}
|
||||
<div className="cp-card p-6 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-lg">
|
||||
<UserPlusIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900 dark:text-slate-50">افزودن پزشک جدید</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
اطلاعات پزشک را وارد کنید. اگر شماره موبایل از قبل در سیستم باشد، پروفایل پزشک به همان کاربر متصل میشود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* Account info */}
|
||||
<div className="cp-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3">
|
||||
اطلاعات حساب
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Mobile */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
شماره موبایل <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" dir="ltr"
|
||||
className={`cp-input text-left ${errors.mobile ? 'border-red-400 focus:ring-red-400' : ''}`}
|
||||
placeholder="09xxxxxxxxx"
|
||||
{...register('mobile')} />
|
||||
{errors.mobile && <p className="text-xs text-red-500 mt-1">{errors.mobile.message}</p>}
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
اگر کاربر با این شماره وجود داشته باشد، پروفایل پزشک به آن متصل میشود
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
نام کامل <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
className={`cp-input ${errors.name ? 'border-red-400 focus:ring-red-400' : ''}`}
|
||||
placeholder="دکتر محمد احمدی"
|
||||
{...register('name')} />
|
||||
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Professional info */}
|
||||
<div className="cp-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3">
|
||||
اطلاعات حرفهای
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">جنسیت</label>
|
||||
<select className="cp-select h-11" {...register('gender')}>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{GENDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Degree */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">درجه تحصیلی</label>
|
||||
<select className="cp-select h-11" {...register('degree')}>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{DEGREE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Medical system code */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">کد نظام پزشکی</label>
|
||||
<input type="text" dir="ltr"
|
||||
className="cp-input text-left"
|
||||
placeholder="123456"
|
||||
{...register('medical_system_code')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بیوگرافی</label>
|
||||
<textarea rows={4}
|
||||
className="cp-input resize-none"
|
||||
placeholder="معرفی کوتاهی از پزشک..."
|
||||
{...register('info')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specialties */}
|
||||
<div className="cp-card p-6 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3">
|
||||
تخصصها
|
||||
{selectedSpecialties.length > 0 && (
|
||||
<span className="text-xs font-normal text-primary-600 dark:text-primary-400 mr-2">
|
||||
{formatNumber(selectedSpecialties.length)} انتخاب شده
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{specialtiesQ.isLoading ? (
|
||||
<div className="h-32 rounded-xl skeleton" />
|
||||
) : (
|
||||
<SpecialtyPicker
|
||||
selected={selectedSpecialties}
|
||||
onChange={setSelectedSpecialties}
|
||||
specialties={specialties}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pb-4">
|
||||
<button type="button" onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary px-6">
|
||||
لغو
|
||||
</button>
|
||||
<button type="submit" disabled={createMut.isPending} className="cp-btn-primary px-8">
|
||||
{createMut.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
در حال ذخیره...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlusIcon className="w-4 h-4" />افزودن پزشک
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+859
-121
File diff suppressed because it is too large
Load Diff
@@ -1,103 +1,600 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ArrowRightIcon, PencilIcon, TrashIcon, CheckCircleIcon,
|
||||
XCircleIcon, ShieldCheckIcon, UserIcon, PhoneIcon,
|
||||
EnvelopeIcon, CalendarIcon, KeyIcon, EllipsisVerticalIcon,
|
||||
ClipboardDocumentIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { User } from '../types';
|
||||
import { formatDate, formatDateTime } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AdminUserDetail {
|
||||
uuid: string;
|
||||
id: number;
|
||||
mobile_number: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
roles: string[];
|
||||
is_active: boolean;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_META: Record<string, { label: string; cls: string; dot: string }> = {
|
||||
admin: { label: 'ادمین', cls: 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', dot: '#8b5cf6' },
|
||||
doctor: { label: 'پزشک', cls: 'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300', dot: '#3b82f6' },
|
||||
secretary: { label: 'منشی', cls: 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', dot: '#f97316' },
|
||||
clinic: { label: 'کلینیک', cls: 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', dot: '#10b981' },
|
||||
patient: { label: 'بیمار', cls: 'bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400', dot: '#94a3b8' },
|
||||
};
|
||||
|
||||
const ROLE_MAP: Record<string, string[]> = {
|
||||
admin: ['ROLE_USER', 'ROLE_ADMIN'],
|
||||
doctor: ['ROLE_USER', 'ROLE_DOCTOR'],
|
||||
secretary: ['ROLE_USER', 'ROLE_SECRETARY'],
|
||||
clinic: ['ROLE_USER', 'ROLE_CLINIC'],
|
||||
patient: ['ROLE_USER'],
|
||||
};
|
||||
|
||||
function getPrimaryRole(roles: string[]): string {
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin';
|
||||
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
|
||||
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
|
||||
if (roles.includes('ROLE_CLINIC')) return 'clinic';
|
||||
return 'patient';
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'from-violet-500 to-purple-600',
|
||||
'from-blue-500 to-cyan-600',
|
||||
'from-emerald-500 to-teal-600',
|
||||
'from-pink-500 to-rose-600',
|
||||
'from-amber-500 to-orange-600',
|
||||
];
|
||||
|
||||
function BigAvatar({ name, id }: { name: string | null; id: number }) {
|
||||
const initials = name
|
||||
? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
: '؟';
|
||||
return (
|
||||
<div className="cp-info-row">
|
||||
<span className="cp-info-label text-sm">{label}</span>
|
||||
<span className="cp-info-value">{value ?? '—'}</span>
|
||||
<div className={`w-20 h-20 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[id % AVATAR_COLORS.length]} flex items-center justify-center text-white text-2xl font-bold shadow-lg shrink-0`}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['user', uuid],
|
||||
queryFn: () => api.get<ApiResponse<User>>(`/api/v1/user-profile/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const user = data?.data;
|
||||
|
||||
function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }: {
|
||||
icon: React.ElementType; label: string; value: React.ReactNode;
|
||||
mono?: boolean; copyable?: boolean;
|
||||
}) {
|
||||
const handleCopy = () => {
|
||||
if (typeof value === 'string') {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success('کپی شد');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جزئیات کاربر"
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'کاربران', to: '/admin/users' },
|
||||
{ label: 'جزئیات' },
|
||||
]}
|
||||
action={
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors"
|
||||
>
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
بازگشت
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="cp-card p-6 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-8 rounded-lg skeleton" />
|
||||
))}
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">اطلاعات پایه</h3>
|
||||
<InfoRow label="شناسه" value={<span dir="ltr" className="font-mono text-xs">{user.uuid}</span>} />
|
||||
<InfoRow
|
||||
label="نام و نام خانوادگی"
|
||||
value={`${user.first_name ?? ''} ${user.last_name ?? ''}`.trim() || '—'}
|
||||
/>
|
||||
<InfoRow label="موبایل" value={<span dir="ltr">{user.mobile_number}</span>} />
|
||||
<InfoRow label="ایمیل" value={user.email} />
|
||||
<InfoRow
|
||||
label="نقش"
|
||||
value={
|
||||
user.roles.includes('ROLE_ADMIN')
|
||||
? 'ادمین'
|
||||
: user.roles.includes('ROLE_DOCTOR')
|
||||
? 'پزشک'
|
||||
: 'کاربر'
|
||||
}
|
||||
/>
|
||||
<InfoRow label="وضعیت" value={<ActiveBadge active={user.is_active} />} />
|
||||
<InfoRow label="تاریخ ثبتنام" value={formatDate(user.created_at)} />
|
||||
<InfoRow label="آخرین بروزرسانی" value={formatDateTime(user.updated_at)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">نقشها و دسترسیها</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.roles.map((role) => (
|
||||
<span key={role} className="px-3 py-1 bg-primary-50 text-primary-700 text-xs rounded-full font-medium">
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
کاربری یافت نشد
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-gray-800/60 hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors group">
|
||||
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
|
||||
<Icon className="w-4.5 h-4.5 text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-0.5">{label}</p>
|
||||
<p className={`text-sm font-medium text-slate-800 dark:text-slate-200 truncate ${mono ? 'font-mono' : ''}`}
|
||||
dir={mono ? 'ltr' : undefined}>
|
||||
{value ?? <span className="text-slate-400 dark:text-slate-500 font-normal">ثبت نشده</span>}
|
||||
</p>
|
||||
</div>
|
||||
{copyable && typeof value === 'string' && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-white dark:hover:bg-gray-700 transition-all shrink-0"
|
||||
>
|
||||
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edit schema ───────────────────────────────────────────────────────────
|
||||
|
||||
const editSchema = z.object({
|
||||
name: z.string().max(100).optional().or(z.literal('')),
|
||||
email: z.string().email('ایمیل نامعتبر').optional().or(z.literal('')),
|
||||
password: z.string().min(6, 'رمز عبور حداقل ۶ کاراکتر').optional().or(z.literal('')),
|
||||
});
|
||||
type EditForm = z.infer<typeof editSchema>;
|
||||
|
||||
// ── Role Change Modal ─────────────────────────────────────────────────────
|
||||
|
||||
function ChangeRoleModal({ current, loading, onSave, onClose }: {
|
||||
current: string; loading: boolean;
|
||||
onSave: (role: string) => void; onClose: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(current);
|
||||
return (
|
||||
<Modal open title="تغییر نقش کاربر" onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
|
||||
<button onClick={() => onSave(selected)} disabled={loading} className="cp-btn-primary">
|
||||
{loading ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(ROLE_META).map(([key, meta]) => (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer border-2 transition-all ${
|
||||
selected === key
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
|
||||
: 'border-transparent hover:bg-slate-50 dark:hover:bg-gray-800 border border-slate-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<input type="radio" name="role" value={key} checked={selected === key}
|
||||
onChange={() => setSelected(key)} className="sr-only" />
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} />
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-200 flex-1">{meta.label}</span>
|
||||
{selected === key && <CheckCircleIcon className="w-4 h-4 text-primary-600 dark:text-primary-400" />}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
|
||||
const [roleOpen, setRoleOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('[data-upmenu]') === null) setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [menuOpen]);
|
||||
|
||||
// ── Query ──
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['admin-user', uuid],
|
||||
queryFn: () => api.get<ApiResponse<AdminUserDetail>>(`/api/v1/admin/users/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const user: AdminUserDetail | undefined = (data?.data as any)?.data ?? data?.data;
|
||||
|
||||
// ── Edit form ──
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<EditForm>({
|
||||
resolver: zodResolver(editSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) reset({ name: user.name ?? '', email: user.email ?? '', password: '' });
|
||||
}, [user, reset]);
|
||||
|
||||
// ── Mutations ──
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (body: EditForm) => api.put<ApiResponse<AdminUserDetail>>(`/api/v1/admin/users/${uuid}`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('اطلاعات بروزرسانی شد');
|
||||
setEditOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ['admin-user', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const toggleStatusMut = useMutation({
|
||||
mutationFn: () => api.post<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/users/${uuid}/status`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('وضعیت کاربر تغییر کرد');
|
||||
qc.invalidateQueries({ queryKey: ['admin-user', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updateRoleMut = useMutation({
|
||||
mutationFn: (role: string) => api.put<ApiResponse<null>>(`/api/v1/admin/users/${uuid}/role`, { role }),
|
||||
onSuccess: () => {
|
||||
toast.success('نقش کاربر بروزرسانی شد');
|
||||
setRoleOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ['admin-user', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/admin/users/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('کاربر حذف شد');
|
||||
navigate('/admin/users');
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// ── Loading skeleton ──
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-slide-up space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg skeleton" />
|
||||
<div className="h-5 w-32 rounded skeleton" />
|
||||
</div>
|
||||
<div className="cp-card p-6">
|
||||
<div className="flex items-start gap-5 mb-8">
|
||||
<div className="w-20 h-20 rounded-2xl skeleton" />
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<div className="h-6 w-48 rounded skeleton" />
|
||||
<div className="h-4 w-24 rounded skeleton" />
|
||||
<div className="h-5 w-16 rounded-full skeleton mt-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-16 rounded-xl skeleton" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !user) {
|
||||
return (
|
||||
<div className="animate-slide-up">
|
||||
<div className="cp-card p-16 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-400/10 flex items-center justify-center">
|
||||
<XCircleIcon className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<p className="font-semibold text-slate-800 dark:text-slate-200">کاربر یافت نشد</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">این کاربر حذف شده یا UUID اشتباه است.</p>
|
||||
<button onClick={() => navigate('/admin/users')} className="cp-btn-secondary mt-2">
|
||||
بازگشت به لیست کاربران
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryRole = getPrimaryRole(user.roles);
|
||||
const roleMeta = ROLE_META[primaryRole];
|
||||
|
||||
return (
|
||||
<div className="animate-slide-up space-y-5">
|
||||
|
||||
{/* ── Breadcrumb / Back ─────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors"
|
||||
>
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
کاربران
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل کاربر</span>
|
||||
</div>
|
||||
|
||||
{/* ── Profile Card ──────────────────────────────────────── */}
|
||||
<div className="cp-card overflow-hidden">
|
||||
|
||||
{/* Cover strip */}
|
||||
<div className="h-24 bg-gradient-to-l from-primary-600/20 via-primary-500/10 to-transparent dark:from-primary-500/20" />
|
||||
|
||||
{/* Profile header */}
|
||||
<div className="px-6 pb-6 -mt-10 flex flex-col sm:flex-row sm:items-end gap-4">
|
||||
<BigAvatar name={user.name} id={user.id} />
|
||||
|
||||
<div className="flex-1 min-w-0 sm:mb-1">
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50 leading-tight">
|
||||
{user.name || <span className="text-slate-400 font-normal">نام ثبت نشده</span>}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full ${roleMeta.cls}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: roleMeta.dot }} />
|
||||
{roleMeta.label}
|
||||
</span>
|
||||
{user.is_active ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-green-100 dark:bg-green-400/10 text-green-700 dark:text-green-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />فعال
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-400/10 text-slate-500 dark:text-slate-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />غیرفعال
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">#{user.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 sm:mb-1">
|
||||
<button
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="cp-btn-primary text-sm"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
ویرایش اطلاعات
|
||||
</button>
|
||||
|
||||
{/* More menu */}
|
||||
<div className="relative" data-upmenu>
|
||||
<button
|
||||
data-upmenu
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="cp-btn-secondary h-10 w-10 flex items-center justify-center px-0"
|
||||
>
|
||||
<EllipsisVerticalIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute left-0 top-12 z-30 w-52 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data-upmenu>
|
||||
<button
|
||||
onClick={() => { toggleStatusMut.mutate(); setMenuOpen(false); }}
|
||||
disabled={toggleStatusMut.isPending}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{user.is_active
|
||||
? <XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />
|
||||
: <CheckCircleIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
}
|
||||
{user.is_active ? 'غیرفعال کردن حساب' : 'فعالسازی حساب'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRoleOpen(true); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<ShieldCheckIcon className="w-4 h-4 text-violet-400 shrink-0" />
|
||||
تغییر نقش کاربر
|
||||
</button>
|
||||
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
|
||||
<button
|
||||
onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 shrink-0" />
|
||||
حذف این کاربر
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Info Grid ─────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
|
||||
{/* Left: Contact info */}
|
||||
<div className="lg:col-span-2 cp-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">اطلاعات تماس و حساب</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<InfoCard icon={UserIcon} label="نام کامل" value={user.name} />
|
||||
<InfoCard icon={PhoneIcon} label="شماره موبایل" value={user.mobile_number} mono copyable />
|
||||
<InfoCard icon={EnvelopeIcon} label="آدرس ایمیل" value={user.email} copyable />
|
||||
<InfoCard icon={ShieldCheckIcon} label="نقش در سیستم" value={roleMeta.label} />
|
||||
<InfoCard icon={CalendarIcon} label="تاریخ عضویت" value={formatDate(user.created_at)} />
|
||||
<InfoCard icon={CalendarIcon} label="آخرین بروزرسانی" value={formatDateTime(user.updated_at)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Roles + UUID */}
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Roles card */}
|
||||
<div className="cp-card p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">نقشهای سیستمی</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.roles.map((r) => (
|
||||
<span key={r} className="text-xs font-mono px-2.5 py-1 rounded-lg bg-slate-100 dark:bg-gray-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-700">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRoleOpen(true)}
|
||||
className="w-full mt-1 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right"
|
||||
>
|
||||
تغییر نقش ←
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* UUID card */}
|
||||
<div className="cp-card p-5 space-y-2">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">شناسه یکتا (UUID)</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[11px] font-mono text-slate-500 dark:text-slate-400 break-all flex-1" dir="ltr">
|
||||
{user.uuid}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(user.uuid); toast.success('UUID کپی شد'); }}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors shrink-0"
|
||||
>
|
||||
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status card */}
|
||||
<div className="cp-card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">وضعیت حساب</h2>
|
||||
{user.is_active
|
||||
? <span className="text-xs font-medium text-green-600 dark:text-green-400">فعال</span>
|
||||
: <span className="text-xs font-medium text-slate-500">غیرفعال</span>
|
||||
}
|
||||
</div>
|
||||
<div className={`w-full h-2 rounded-full ${user.is_active ? 'bg-green-200 dark:bg-green-400/20' : 'bg-slate-200 dark:bg-gray-700'}`}>
|
||||
<div className={`h-full rounded-full transition-all ${user.is_active ? 'w-full bg-green-500' : 'w-0'}`} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleStatusMut.mutate()}
|
||||
disabled={toggleStatusMut.isPending}
|
||||
className={`w-full mt-4 text-sm font-medium py-2 rounded-xl transition-colors disabled:opacity-50 ${
|
||||
user.is_active
|
||||
? 'bg-orange-50 dark:bg-orange-400/10 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-400/20'
|
||||
: 'bg-green-50 dark:bg-green-400/10 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-400/20'
|
||||
}`}
|
||||
>
|
||||
{toggleStatusMut.isPending
|
||||
? 'در حال تغییر...'
|
||||
: user.is_active ? 'غیرفعال کردن حساب' : 'فعالسازی حساب'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Edit Modal ────────────────────────────────────────── */}
|
||||
<Modal
|
||||
open={editOpen}
|
||||
title="ویرایش اطلاعات کاربر"
|
||||
size="md"
|
||||
onClose={() => setEditOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => setEditOpen(false)} className="cp-btn-secondary px-5">لغو</button>
|
||||
<button
|
||||
form="edit-user-form"
|
||||
type="submit"
|
||||
disabled={updateMut.isPending}
|
||||
className="cp-btn-primary"
|
||||
>
|
||||
{updateMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-user-form" onSubmit={handleSubmit((v) => updateMut.mutate(v))} className="space-y-4">
|
||||
|
||||
{/* Avatar preview */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-slate-100 dark:border-gray-700">
|
||||
<BigAvatar name={user.name} id={user.id} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||
{user.name || 'نام ثبت نشده'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-mono" dir="ltr">{user.mobile_number}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
نام و نام خانوادگی
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="مثال: علی احمدی"
|
||||
className={`cp-input ${errors.name ? 'border-red-400 dark:border-red-500' : ''}`}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500 dark:text-red-400 mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
آدرس ایمیل
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="example@email.com"
|
||||
dir="ltr"
|
||||
className={`cp-input text-left ${errors.email ? 'border-red-400 dark:border-red-500' : ''}`}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-red-500 dark:text-red-400 mt-1">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
رمز عبور جدید
|
||||
<span className="text-xs font-normal text-slate-400 dark:text-slate-500 mr-1">(خالی = بدون تغییر)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<KeyIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="حداقل ۶ کاراکتر"
|
||||
dir="ltr"
|
||||
className={`cp-input pr-9 text-left ${errors.password ? 'border-red-400 dark:border-red-500' : ''}`}
|
||||
{...register('password')}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-red-500 dark:text-red-400 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Mobile (read-only) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
شماره موبایل
|
||||
<span className="text-xs font-normal text-slate-400 dark:text-slate-500 mr-1">(قابل تغییر نیست)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={user.mobile_number}
|
||||
readOnly
|
||||
dir="ltr"
|
||||
className="cp-input text-left bg-slate-50 dark:bg-gray-800/60 text-slate-400 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* ── Role change modal ─────────────────────────────────── */}
|
||||
{roleOpen && (
|
||||
<ChangeRoleModal
|
||||
current={primaryRole}
|
||||
loading={updateRoleMut.isPending}
|
||||
onSave={(r) => updateRoleMut.mutate(r)}
|
||||
onClose={() => setRoleOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Delete confirm ────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
title="حذف کاربر"
|
||||
message={`آیا از حذف کاربر "${user.name ?? user.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام دادههای مرتبط پاک میشود.`}
|
||||
confirmLabel="بله، حذف کن"
|
||||
danger
|
||||
loading={deleteMut.isPending}
|
||||
onConfirm={() => deleteMut.mutate()}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+631
-102
@@ -1,142 +1,671 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
MagnifyingGlassIcon, PlusIcon, EllipsisVerticalIcon,
|
||||
EyeIcon, PencilIcon, TrashIcon, ArrowPathIcon,
|
||||
UserGroupIcon, CheckCircleIcon, XCircleIcon,
|
||||
ShieldCheckIcon, HeartIcon, UserIcon, FunnelIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { User } from '../types';
|
||||
import { formatDate, maskMobile } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AdminUser {
|
||||
uuid: string;
|
||||
id: number;
|
||||
mobile_number: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
roles: string[];
|
||||
is_active: boolean;
|
||||
status: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface UserStats {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
admins: number;
|
||||
doctors: number;
|
||||
patients: number;
|
||||
}
|
||||
|
||||
// ── Role helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_META: Record<string, { label: string; cls: string; dot: string }> = {
|
||||
admin: { label: 'ادمین', cls: 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', dot: '#8b5cf6' },
|
||||
doctor: { label: 'پزشک', cls: 'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300', dot: '#3b82f6' },
|
||||
secretary: { label: 'منشی', cls: 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', dot: '#f97316' },
|
||||
clinic: { label: 'کلینیک', cls: 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', dot: '#10b981' },
|
||||
patient: { label: 'بیمار', cls: 'bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400', dot: '#94a3b8' },
|
||||
};
|
||||
|
||||
function getPrimaryRole(roles: string[]): string {
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin';
|
||||
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
|
||||
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
|
||||
if (roles.includes('ROLE_CLINIC')) return 'clinic';
|
||||
return 'patient';
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-400/20 dark:text-violet-300',
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-400/20 dark:text-blue-300',
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-400/20 dark:text-emerald-300',
|
||||
'bg-pink-100 text-pink-700 dark:bg-pink-400/20 dark:text-pink-300',
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-400/20 dark:text-amber-300',
|
||||
];
|
||||
|
||||
function Avatar({ name, id }: { name: string | null; id: number }) {
|
||||
const initials = name
|
||||
? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
: '؟';
|
||||
return (
|
||||
<div className={`w-8 h-8 rounded-full ${AVATAR_COLORS[id % AVATAR_COLORS.length]} flex items-center justify-center text-xs font-bold shrink-0`}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleBadge({ roles }: { roles: string[] }) {
|
||||
const key = getPrimaryRole(roles);
|
||||
const meta = ROLE_META[key];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full ${meta.cls}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveBadge({ active }: { active: boolean }) {
|
||||
return active ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-400/10 text-green-700 dark:text-green-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
فعال
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-400/10 text-slate-500 dark:text-slate-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 shrink-0" />
|
||||
غیرفعال
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function highlight(text: string | null | undefined, query: string): React.ReactNode {
|
||||
if (!text || !query.trim()) return text ?? '—';
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="bg-yellow-100 dark:bg-yellow-400/20 text-yellow-800 dark:text-yellow-200 rounded px-0.5 not-italic">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({ label, value, icon: Icon, iconBg, iconColor }: {
|
||||
label: string; value: number | undefined;
|
||||
icon: React.ElementType; iconBg: string; iconColor: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="cp-card p-4 flex items-center gap-3">
|
||||
<div className={`cp-stat-icon ${iconBg} shrink-0`}>
|
||||
<Icon className={`w-5 h-5 ${iconColor}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5 truncate">{label}</p>
|
||||
{value === undefined
|
||||
? <div className="h-5 w-16 rounded skeleton" />
|
||||
: <p className="text-lg font-bold text-slate-900 dark:text-slate-50 leading-none">{formatNumber(value)}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Role Change Modal ─────────────────────────────────────────────────────
|
||||
|
||||
function ChangeRoleModal({ user, onClose, onSave, loading }: {
|
||||
user: AdminUser; onClose: () => void;
|
||||
onSave: (role: string) => void; loading: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm animate-fade-in">
|
||||
<div className="cp-card w-full max-w-sm p-6 animate-scale-in shadow-2xl">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-slate-50 mb-1">تغییر نقش</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">{user.name || user.mobile_number}</p>
|
||||
<div className="space-y-2 mb-6">
|
||||
{Object.entries(ROLE_META).map(([key, meta]) => (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer border-2 transition-all ${
|
||||
selected === key
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
|
||||
: 'border-transparent hover:bg-slate-50 dark:hover:bg-gray-800 border border-slate-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<input type="radio" name="role" value={key} checked={selected === key}
|
||||
onChange={() => setSelected(key)} className="sr-only" />
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} />
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-200 flex-1">{meta.label}</span>
|
||||
{selected === key && <CheckCircleIcon className="w-4 h-4 text-primary-600 dark:text-primary-400" />}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onSave(selected)} disabled={loading} className="cp-btn-primary flex-1">
|
||||
{loading ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
<button onClick={onClose} disabled={loading} className="cp-btn-secondary px-5">لغو</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_TABS = [
|
||||
{ key: '', label: 'همه', dot: '' },
|
||||
{ key: 'admin', label: 'ادمین', dot: '#8b5cf6' },
|
||||
{ key: 'doctor', label: 'پزشک', dot: '#3b82f6' },
|
||||
{ key: 'secretary', label: 'منشی', dot: '#f97316' },
|
||||
{ key: 'clinic', label: 'کلینیک', dot: '#10b981' },
|
||||
{ key: 'patient', label: 'بیمار', dot: '#94a3b8' },
|
||||
];
|
||||
|
||||
const LIMIT_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['users', page, search],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<User>>(
|
||||
`/api/v1/admin/users?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
|
||||
),
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(25);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [role, setRole] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [sort, setSort] = useState('newest');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
||||
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
||||
const [confirmBulkDel, setConfirmBulkDel] = useState(false);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// Reset page + selection when filters change
|
||||
useEffect(() => { setPage(1); setSelected([]); }, [role, status, sort, limit]);
|
||||
|
||||
// Close action dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!activeMenu) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('[data-menu]') === null) setActiveMenu(null);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [activeMenu]);
|
||||
|
||||
// ── Queries ──
|
||||
|
||||
const statsQ = useQuery({
|
||||
queryKey: ['users-stats'],
|
||||
queryFn: () => api.get<ApiResponse<UserStats>>('/api/v1/admin/users/stats'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (user: User) => api.delete<ApiResponse<null>>(`/api/v1/user/${user.id}`),
|
||||
const usersQ = useQuery({
|
||||
queryKey: ['users', page, limit, search, role, status, sort],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams({ page: String(page), limit: String(limit), sort });
|
||||
if (search) p.set('search', search);
|
||||
if (role) p.set('role', role);
|
||||
if (status) p.set('status', status);
|
||||
return api.get<PaginatedResponse<AdminUser>>(`/api/v1/admin/users?${p}`);
|
||||
},
|
||||
});
|
||||
|
||||
const stats = useMemo<UserStats | undefined>(
|
||||
() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]
|
||||
);
|
||||
const items = usersQ.data?.data ?? [];
|
||||
const total = usersQ.data?.meta?.totalRecords ?? 0;
|
||||
|
||||
// ── Mutations ──
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/users/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('کاربر حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const columns: Column<User>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'نام',
|
||||
render: (u) => (
|
||||
<span className="font-medium text-slate-800 dark:text-slate-100">
|
||||
{u.name || (u.first_name || u.last_name ? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim() : '—')}
|
||||
</span>
|
||||
),
|
||||
const toggleStatusMut = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/users/${uuid}/status`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('وضعیت کاربر تغییر کرد');
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
||||
},
|
||||
{
|
||||
key: 'mobile_number',
|
||||
header: 'موبایل',
|
||||
render: (u) => <span dir="ltr">{maskMobile(u.mobile_number)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
header: 'نقش',
|
||||
render: (u) => (
|
||||
<span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200 px-2 py-0.5 rounded-full">
|
||||
{u.roles.includes('ROLE_ADMIN')
|
||||
? 'ادمین'
|
||||
: u.roles.includes('ROLE_DOCTOR')
|
||||
? 'پزشک'
|
||||
: 'کاربر'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_active',
|
||||
header: 'وضعیت',
|
||||
render: (u) => <ActiveBadge active={u.is_active} />,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'تاریخ ثبت',
|
||||
render: (u) => formatDate(u.created_at),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updateRoleMut = useMutation({
|
||||
mutationFn: ({ uuid, role: r }: { uuid: string; role: string }) =>
|
||||
api.put<ApiResponse<null>>(`/api/v1/admin/users/${uuid}/role`, { role: r }),
|
||||
onSuccess: () => {
|
||||
toast.success('نقش کاربر بروزرسانی شد');
|
||||
setRoleTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// ── Bulk actions ──
|
||||
|
||||
const allSelected = items.length > 0 && items.every(u => selected.includes(u.uuid));
|
||||
const toggleAll = () => setSelected(allSelected ? [] : items.map(u => u.uuid));
|
||||
const toggleOne = (uuid: string) =>
|
||||
setSelected(prev => prev.includes(uuid) ? prev.filter(id => id !== uuid) : [...prev, uuid]);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
setBulkDeleting(true);
|
||||
let count = 0;
|
||||
for (const uuid of selected) {
|
||||
try { await api.delete(`/api/v1/admin/users/${uuid}`); count++; } catch {}
|
||||
}
|
||||
setBulkDeleting(false);
|
||||
setConfirmBulkDel(false);
|
||||
setSelected([]);
|
||||
toast.success(`${formatNumber(count)} کاربر حذف شد`);
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
||||
};
|
||||
|
||||
// ── KPI cards ──
|
||||
|
||||
const kpiCards = [
|
||||
{ label: 'کل کاربران', value: stats?.total, icon: UserGroupIcon, iconBg: 'bg-slate-100 dark:bg-slate-400/10', iconColor: 'text-slate-600 dark:text-slate-400' },
|
||||
{ label: 'فعال', value: stats?.active, icon: CheckCircleIcon, iconBg: 'bg-green-100 dark:bg-green-400/10', iconColor: 'text-green-600 dark:text-green-400' },
|
||||
{ label: 'غیرفعال', value: stats?.inactive, icon: XCircleIcon, iconBg: 'bg-slate-100 dark:bg-slate-400/10', iconColor: 'text-slate-500 dark:text-slate-400' },
|
||||
{ label: 'ادمینها', value: stats?.admins, icon: ShieldCheckIcon, iconBg: 'bg-violet-100 dark:bg-violet-400/10', iconColor: 'text-violet-600 dark:text-violet-400' },
|
||||
{ label: 'پزشکان', value: stats?.doctors, icon: HeartIcon, iconBg: 'bg-blue-100 dark:bg-blue-400/10', iconColor: 'text-blue-600 dark:text-blue-400' },
|
||||
{ label: 'بیماران', value: stats?.patients, icon: UserIcon, iconBg: 'bg-orange-100 dark:bg-orange-400/10', iconColor: 'text-orange-600 dark:text-orange-400' },
|
||||
];
|
||||
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
const hasFilters = !!(search || role || status);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="کاربران"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'کاربران' }]}
|
||||
/>
|
||||
<div className="animate-slide-up space-y-5">
|
||||
|
||||
<div className="cp-card p-6">
|
||||
<DataTable<User>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس نام یا موبایل..."
|
||||
emptyMessage="هیچ کاربری یافت نشد"
|
||||
actions={(user) => (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/users/${user.uuid}`)}
|
||||
className="cp-action-view"
|
||||
title="مشاهده"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/users/${user.uuid}?edit=1`)}
|
||||
className="cp-action-edit"
|
||||
title="ویرایش"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(user)}
|
||||
className="cp-action-delete"
|
||||
title="حذف"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">کاربران</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">مدیریت کاربران، نقشها و سطح دسترسی سیستم</p>
|
||||
</div>
|
||||
<button className="cp-btn-primary shrink-0">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
افزودن کاربر
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── KPI Cards ──────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{kpiCards.map((c) => <KpiCard key={c.label} {...c} />)}
|
||||
</div>
|
||||
|
||||
{/* ── Main Card ──────────────────────────────────────────── */}
|
||||
<div className="cp-card overflow-hidden">
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-slate-200 dark:border-gray-700/50 space-y-3">
|
||||
|
||||
{/* Row 1: Search + Status + Sort + Limit + Refresh */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-52">
|
||||
<MagnifyingGlassIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="نام، موبایل یا ایمیل..."
|
||||
className="cp-input pr-9"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(''); setSearch(''); }}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-lg leading-none"
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} className="cp-select h-10 w-auto min-w-36 text-sm">
|
||||
<option value="">همه وضعیتها</option>
|
||||
<option value="1">فعال</option>
|
||||
<option value="0">غیرفعال</option>
|
||||
</select>
|
||||
|
||||
<select value={sort} onChange={(e) => setSort(e.target.value)} className="cp-select h-10 w-auto min-w-32 text-sm">
|
||||
<option value="newest">جدیدترین</option>
|
||||
<option value="oldest">قدیمیترین</option>
|
||||
</select>
|
||||
|
||||
<select value={limit} onChange={(e) => setLimit(Number(e.target.value))} className="cp-select h-10 w-auto min-w-24 text-sm">
|
||||
{LIMIT_OPTIONS.map(l => <option key={l} value={l}>{formatNumber(l)} عدد</option>)}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => usersQ.refetch()}
|
||||
disabled={usersQ.isFetching}
|
||||
className="cp-btn-secondary h-10 px-3"
|
||||
title="بهروزرسانی"
|
||||
>
|
||||
<ArrowPathIcon className={`w-4 h-4 ${usersQ.isFetching ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Role filter pills */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400 shrink-0">
|
||||
<FunnelIcon className="w-3.5 h-3.5" />نقش:
|
||||
</span>
|
||||
{ROLE_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setRole(t.key)}
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border transition-all ${
|
||||
role === t.key
|
||||
? 'bg-primary-600 border-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t.dot && (
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: role === t.key ? 'white' : t.dot }} />
|
||||
)}
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(''); setSearch(''); setRole(''); setStatus(''); }}
|
||||
className="text-xs text-red-500 dark:text-red-400 hover:underline mr-2"
|
||||
>
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selected.length > 0 && (
|
||||
<div className="px-4 py-2.5 bg-primary-50 dark:bg-primary-500/10 border-b border-primary-200 dark:border-primary-500/20 flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{formatNumber(selected.length)} کاربر انتخاب شده
|
||||
</span>
|
||||
<div className="flex gap-2 mr-auto">
|
||||
<button
|
||||
onClick={() => setConfirmBulkDel(true)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-red-100 dark:bg-red-400/10 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-400/20 transition-colors font-medium"
|
||||
>
|
||||
حذف انتخابشدهها
|
||||
</button>
|
||||
<button onClick={() => setSelected([])} className="text-xs px-3 py-1.5 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors">
|
||||
لغو انتخاب
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-gray-800/60 border-b border-slate-200 dark:border-gray-700/50">
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll}
|
||||
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 cursor-pointer" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap">کاربر</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden sm:table-cell">موبایل</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden md:table-cell">ایمیل</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap">نقش</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden sm:table-cell">وضعیت</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden lg:table-cell">عضویت</th>
|
||||
<th className="px-4 py-3 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-700/40">
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{usersQ.isLoading && Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3.5"><div className="w-4 h-4 rounded skeleton" /></td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full skeleton shrink-0" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<div className="h-3.5 rounded skeleton w-28" />
|
||||
<div className="h-3 rounded skeleton w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden sm:table-cell"><div className="h-3.5 rounded skeleton w-24" /></td>
|
||||
<td className="px-4 py-3.5 hidden md:table-cell"><div className="h-3.5 rounded skeleton w-36" /></td>
|
||||
<td className="px-4 py-3.5"><div className="h-5 rounded-full skeleton w-14" /></td>
|
||||
<td className="px-4 py-3.5 hidden sm:table-cell"><div className="h-5 rounded-full skeleton w-12" /></td>
|
||||
<td className="px-4 py-3.5 hidden lg:table-cell"><div className="h-3.5 rounded skeleton w-20" /></td>
|
||||
<td className="px-4 py-3.5"><div className="w-6 h-6 rounded skeleton" /></td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{!usersQ.isLoading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<UserGroupIcon className="w-7 h-7 text-slate-400 dark:text-slate-500" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{hasFilters ? 'نتیجهای برای فیلترهای انتخابی یافت نشد' : 'هیچ کاربری ثبت نشده'}
|
||||
</p>
|
||||
{hasFilters && (
|
||||
<button onClick={() => { setSearchInput(''); setSearch(''); setRole(''); setStatus(''); }}
|
||||
className="text-xs text-primary-600 dark:text-primary-400 hover:underline">
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Data rows */}
|
||||
{!usersQ.isLoading && items.map((user) => (
|
||||
<tr
|
||||
key={user.uuid}
|
||||
className={`transition-colors ${
|
||||
selected.includes(user.uuid)
|
||||
? 'bg-primary-50/60 dark:bg-primary-500/5'
|
||||
: 'hover:bg-slate-50/80 dark:hover:bg-gray-800/40'
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected.includes(user.uuid)}
|
||||
onChange={() => toggleOne(user.uuid)}
|
||||
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 cursor-pointer" />
|
||||
</td>
|
||||
|
||||
{/* Avatar + Name */}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3 cursor-pointer group" onClick={() => navigate(`/admin/users/${user.uuid}`)}>
|
||||
<Avatar name={user.name} id={user.id} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-800 dark:text-slate-200 truncate max-w-36 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{highlight(user.name, search) || '—'}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 dark:text-slate-500">#{user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Mobile */}
|
||||
<td className="px-4 py-3.5 hidden sm:table-cell">
|
||||
<span className="font-mono text-xs text-slate-600 dark:text-slate-400" dir="ltr">
|
||||
{highlight(user.mobile_number, search) || maskMobile(user.mobile_number)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Email */}
|
||||
<td className="px-4 py-3.5 hidden md:table-cell">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-44 block">
|
||||
{user.email ? highlight(user.email, search) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Role */}
|
||||
<td className="px-4 py-3.5"><RoleBadge roles={user.roles} /></td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3.5 hidden sm:table-cell"><ActiveBadge active={user.is_active} /></td>
|
||||
|
||||
{/* Date */}
|
||||
<td className="px-4 py-3.5 hidden lg:table-cell text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative flex justify-end" data-menu>
|
||||
<button
|
||||
onClick={() => setActiveMenu(activeMenu === user.uuid ? null : user.uuid)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors"
|
||||
data-menu
|
||||
>
|
||||
<EllipsisVerticalIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{activeMenu === user.uuid && (
|
||||
<div className="absolute left-0 top-8 z-30 w-48 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data-menu>
|
||||
<button
|
||||
onClick={() => { navigate(`/admin/users/${user.uuid}`); setActiveMenu(null); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4 text-slate-400 shrink-0" />مشاهده پروفایل
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigate(`/admin/users/${user.uuid}?edit=1`); setActiveMenu(null); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4 text-slate-400 shrink-0" />ویرایش اطلاعات
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRoleTarget(user); setActiveMenu(null); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<ShieldCheckIcon className="w-4 h-4 text-slate-400 shrink-0" />تغییر نقش
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { toggleStatusMut.mutate(user.uuid); setActiveMenu(null); }}
|
||||
disabled={toggleStatusMut.isPending}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{user.is_active
|
||||
? <XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />
|
||||
: <CheckCircleIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
}
|
||||
{user.is_active ? 'غیرفعال کردن' : 'فعالسازی'}
|
||||
</button>
|
||||
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
|
||||
<button
|
||||
onClick={() => { setDeleteTarget(user); setActiveMenu(null); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 shrink-0" />حذف کاربر
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-4">
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف کاربر"
|
||||
message={`آیا از حذف کاربر "${deleteTarget?.name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
loading={deleteMut.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.uuid)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{/* Bulk delete confirm */}
|
||||
<ConfirmDialog
|
||||
open={confirmBulkDel}
|
||||
title="حذف گروهی"
|
||||
message={`آیا از حذف ${formatNumber(selected.length)} کاربر انتخابشده اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||
confirmLabel={`حذف ${formatNumber(selected.length)} کاربر`}
|
||||
danger
|
||||
loading={bulkDeleting}
|
||||
onConfirm={handleBulkDelete}
|
||||
onCancel={() => setConfirmBulkDel(false)}
|
||||
/>
|
||||
|
||||
{/* Role change modal */}
|
||||
{roleTarget && (
|
||||
<ChangeRoleModal
|
||||
user={roleTarget}
|
||||
loading={updateRoleMut.isPending}
|
||||
onClose={() => setRoleTarget(null)}
|
||||
onSave={(r) => updateRoleMut.mutate({ uuid: roleTarget.uuid, role: r })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ security:
|
||||
security: false
|
||||
|
||||
api:
|
||||
pattern: ^/(api|oauth)/
|
||||
pattern: ^/(api|oauth|file/upload)/
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
custom_authenticators:
|
||||
|
||||
Generated
+49
@@ -10,10 +10,13 @@
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-table": "^8.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.0.0",
|
||||
"react-hot-toast": "^2.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-select": "^5.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -2316,6 +2319,17 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
@@ -3357,6 +3371,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -3391,6 +3411,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
@@ -5851,6 +5880,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -7365,6 +7400,20 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
|
||||
@@ -26,10 +26,13 @@
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-table": "^8.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.0.0",
|
||||
"react-hot-toast": "^2.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-select": "^5.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Auth\Entity\User;
|
||||
use App\Location\Entity\City;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Rating\Entity\Comment;
|
||||
use App\Rating\Entity\Rate;
|
||||
@@ -73,29 +74,168 @@ class AdminApiController extends BaseController
|
||||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/users/stats', methods: ['GET'])]
|
||||
public function userStats(): JsonResponse
|
||||
{
|
||||
$conn = $this->em->getConnection();
|
||||
|
||||
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM users');
|
||||
$active = (int) $conn->fetchOne('SELECT COUNT(*) FROM users WHERE status = 1');
|
||||
$admins = (int) $conn->fetchOne("SELECT COUNT(*) FROM users WHERE roles LIKE '%ROLE_ADMIN%'");
|
||||
$doctors = (int) $conn->fetchOne("SELECT COUNT(*) FROM users WHERE roles LIKE '%ROLE_DOCTOR%' AND roles NOT LIKE '%ROLE_ADMIN%'");
|
||||
|
||||
return $this->success([
|
||||
'total' => $total,
|
||||
'active' => $active,
|
||||
'inactive' => $total - $active,
|
||||
'admins' => $admins,
|
||||
'doctors' => $doctors,
|
||||
'patients' => max(0, $total - $admins - $doctors),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/users/{uuid}/status', methods: ['POST'])]
|
||||
public function toggleUserStatus(string $uuid): JsonResponse
|
||||
{
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
|
||||
|
||||
$user->setStatus($user->getStatus() === 1 ? 0 : 1);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['is_active' => $user->getStatus() === 1, 'status' => $user->getStatus()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/users/{uuid}/role', methods: ['PUT'])]
|
||||
public function updateUserRole(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$role = (string) ($data['role'] ?? '');
|
||||
|
||||
$roleMap = [
|
||||
'admin' => ['ROLE_USER', 'ROLE_ADMIN'],
|
||||
'doctor' => ['ROLE_USER', 'ROLE_DOCTOR'],
|
||||
'secretary' => ['ROLE_USER', 'ROLE_SECRETARY'],
|
||||
'clinic' => ['ROLE_USER', 'ROLE_CLINIC'],
|
||||
'patient' => ['ROLE_USER'],
|
||||
];
|
||||
|
||||
if (!isset($roleMap[$role])) {
|
||||
return $this->error('INVALID_ROLE', 'نقش نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$user->setRoles($roleMap[$role]);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['roles' => $user->getRoles()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/users/{uuid}', methods: ['GET'])]
|
||||
public function userDetail(string $uuid): JsonResponse
|
||||
{
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $user->getUuid(),
|
||||
'id' => $user->getId(),
|
||||
'mobile_number' => $user->getMobileNumber(),
|
||||
'name' => $user->getRealName(),
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $user->getRoles(),
|
||||
'is_active' => $user->getStatus() === 1,
|
||||
'status' => $user->getStatus(),
|
||||
'created_at' => date('c', $user->getCreatedAt()),
|
||||
'updated_at' => date('c', $user->getCreatedAt()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/users/{uuid}', methods: ['PUT'])]
|
||||
public function updateUser(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('name', $data)) {
|
||||
$user->setRealName($data['name'] !== '' ? (string) $data['name'] : null);
|
||||
}
|
||||
if (array_key_exists('email', $data)) {
|
||||
$user->setEmail($data['email'] !== '' ? (string) $data['email'] : null);
|
||||
}
|
||||
if (!empty($data['password'])) {
|
||||
$user->setPasswordHash(password_hash((string) $data['password'], PASSWORD_BCRYPT));
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $user->getUuid(),
|
||||
'name' => $user->getRealName(),
|
||||
'email' => $user->getEmail(),
|
||||
'is_active' => $user->getStatus() === 1,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/users/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteUser(string $uuid): JsonResponse
|
||||
{
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
|
||||
|
||||
$this->em->remove($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(null);
|
||||
}
|
||||
|
||||
#[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)));
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(5, (int) $request->query->get('limit', 25)));
|
||||
$search = trim((string) $request->query->get('search', ''));
|
||||
$role = trim((string) $request->query->get('role', ''));
|
||||
$status = $request->query->get('status', '');
|
||||
$sort = trim((string) $request->query->get('sort', 'newest'));
|
||||
|
||||
$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');
|
||||
->from(User::class, 'u');
|
||||
|
||||
if ($search !== '') {
|
||||
$qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s')
|
||||
->setParameter('s', '%' . $search . '%');
|
||||
}
|
||||
|
||||
if ($role !== '') {
|
||||
match ($role) {
|
||||
'admin' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_ADMIN%'),
|
||||
'doctor' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole")
|
||||
->setParameter('role', '%ROLE_DOCTOR%')->setParameter('notRole', '%ROLE_ADMIN%'),
|
||||
'secretary' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_SECRETARY%'),
|
||||
'clinic' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole")
|
||||
->setParameter('role', '%ROLE_CLINIC%')->setParameter('notRole', '%ROLE_ADMIN%'),
|
||||
'patient' => $qb->andWhere("u.roles NOT LIKE :d AND u.roles NOT LIKE :a")
|
||||
->setParameter('d', '%ROLE_DOCTOR%')->setParameter('a', '%ROLE_ADMIN%'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
if ($status !== '') {
|
||||
$qb->andWhere('u.status = :status')->setParameter('status', (int) $status);
|
||||
}
|
||||
|
||||
$qb->orderBy('u.createdAt', $sort === 'oldest' ? 'ASC' : 'DESC');
|
||||
|
||||
$total = (clone $qb)->select('COUNT(u.id)')->getQuery()->getSingleScalarResult();
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getArrayResult();
|
||||
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$items = array_map(fn(array $u) => [
|
||||
return $this->paginated(array_map(fn(array $u) => [
|
||||
'uuid' => $u['uuid'],
|
||||
'id' => $u['id'],
|
||||
'mobile_number' => $u['mobile'],
|
||||
@@ -103,10 +243,189 @@ class AdminApiController extends BaseController
|
||||
'email' => $u['email'],
|
||||
'roles' => $u['roles'],
|
||||
'is_active' => $u['status'] === 1,
|
||||
'status' => $u['status'],
|
||||
'created_at' => date('c', (int) $u['createdAt']),
|
||||
], $rows);
|
||||
], $rows), (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
// ── Doctors ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/admin/doctors/stats', methods: ['GET'])]
|
||||
public function doctorStats(): JsonResponse
|
||||
{
|
||||
$conn = $this->em->getConnection();
|
||||
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors');
|
||||
$active = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE active_doctor_appointment = 1');
|
||||
$male = (int) $conn->fetchOne("SELECT COUNT(*) FROM doctors WHERE gender IN ('man','male')");
|
||||
$female = (int) $conn->fetchOne("SELECT COUNT(*) FROM doctors WHERE gender IN ('woman','female')");
|
||||
|
||||
$topSpec = $conn->fetchAllAssociative(
|
||||
'SELECT s.name, COUNT(*) as cnt FROM doctor_specialties ds
|
||||
JOIN specialties s ON s.id = ds.specialty_id
|
||||
GROUP BY s.id ORDER BY cnt DESC LIMIT 5'
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'total' => $total,
|
||||
'active' => $active,
|
||||
'inactive' => $total - $active,
|
||||
'male' => $male,
|
||||
'female' => $female,
|
||||
'top_specialty' => $topSpec,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/doctors/{uuid}/status', methods: ['POST'])]
|
||||
public function toggleDoctorStatus(string $uuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
|
||||
|
||||
$doctor->setActiveDoctorAppointment(!$doctor->isActiveDoctorAppointment());
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['is_active' => $doctor->isActiveDoctorAppointment()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/doctors', methods: ['GET'])]
|
||||
public function doctorsList(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(5, (int) $request->query->get('limit', 25)));
|
||||
$search = trim((string) $request->query->get('search', ''));
|
||||
$status = $request->query->get('status', '');
|
||||
$gender = trim((string) $request->query->get('gender', ''));
|
||||
$specId = (int) $request->query->get('specialty_id', 0);
|
||||
$sort = trim((string) $request->query->get('sort', 'newest'));
|
||||
|
||||
$conn = $this->em->getConnection();
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = '(d.name LIKE :s OR d.mobile_number LIKE :s OR u.mobile_number LIKE :s OR u.email LIKE :s)';
|
||||
$params['s'] = '%' . $search . '%';
|
||||
}
|
||||
if ($status !== '') {
|
||||
$where[] = 'd.active_doctor_appointment = :status';
|
||||
$params['status'] = $status === '1' ? 1 : 0;
|
||||
}
|
||||
if ($gender !== '') {
|
||||
$where[] = 'd.gender = :gender';
|
||||
$params['gender'] = $gender;
|
||||
}
|
||||
if ($specId > 0) {
|
||||
$where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)';
|
||||
$params['specId'] = $specId;
|
||||
}
|
||||
|
||||
$whereStr = implode(' AND ', $where);
|
||||
$orderBy = match ($sort) {
|
||||
'oldest' => 'd.created_at ASC',
|
||||
'rating' => 'd.doctor_rate DESC',
|
||||
default => 'd.created_at DESC',
|
||||
};
|
||||
|
||||
$total = (int) $conn->fetchOne(
|
||||
"SELECT COUNT(*) FROM doctors d JOIN users u ON u.id = d.user_id WHERE $whereStr",
|
||||
$params
|
||||
);
|
||||
|
||||
$offset = ($page - 1) * $limit;
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
"SELECT d.id, d.uuid, d.name, d.gender, d.degree, d.medical_system_code,
|
||||
d.mobile_number as doctor_mobile, d.active_doctor_appointment,
|
||||
d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at,
|
||||
u.mobile_number as user_mobile, u.email
|
||||
FROM doctors d JOIN users u ON u.id = d.user_id
|
||||
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset",
|
||||
$params
|
||||
);
|
||||
|
||||
$doctorIds = array_column($rows, 'id');
|
||||
$specMap = [];
|
||||
if (!empty($doctorIds)) {
|
||||
$specRows = $conn->fetchAllAssociative(
|
||||
'SELECT ds.doctor_id, s.id, s.name FROM doctor_specialties ds
|
||||
JOIN specialties s ON s.id = ds.specialty_id
|
||||
WHERE ds.doctor_id IN (' . implode(',', array_map('intval', $doctorIds)) . ')'
|
||||
);
|
||||
foreach ($specRows as $sr) {
|
||||
$specMap[(int) $sr['doctor_id']][] = ['id' => (int) $sr['id'], 'name' => $sr['name']];
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(function (array $d) use ($specMap): array {
|
||||
$images = $d['images'] ? (json_decode($d['images'], true) ?? []) : [];
|
||||
return [
|
||||
'uuid' => $d['uuid'],
|
||||
'id' => (int) $d['id'],
|
||||
'name' => $d['name'],
|
||||
'gender' => $d['gender'],
|
||||
'degree' => $d['degree'],
|
||||
'medical_code' => $d['medical_system_code'],
|
||||
'mobile' => $d['doctor_mobile'] ?: $d['user_mobile'],
|
||||
'email' => $d['email'],
|
||||
'is_active' => (bool) $d['active_doctor_appointment'],
|
||||
'rate' => (float) $d['doctor_rate'],
|
||||
'specialties' => $specMap[(int) $d['id']] ?? [],
|
||||
'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null,
|
||||
'created_at' => date('c', (int) $d['created_at']),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->paginated($items, $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/doctors', methods: ['POST'])]
|
||||
public function createDoctor(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim((string) ($data['mobile'] ?? ''));
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
|
||||
if ($mobile === '' || $name === '') {
|
||||
return $this->error('VALIDATION', 'موبایل و نام الزامی هستند', 422);
|
||||
}
|
||||
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
if (!$user) {
|
||||
$user = new User($mobile);
|
||||
$user->setRealName($name);
|
||||
$user->setPasswordHash(password_hash(bin2hex(random_bytes(8)), PASSWORD_BCRYPT));
|
||||
$this->em->persist($user);
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]);
|
||||
if ($existing) {
|
||||
return $this->error('DOCTOR_EXISTS', 'این کاربر قبلاً پروفایل پزشک دارد', 409);
|
||||
}
|
||||
|
||||
$doctor = new Doctor($user, $name);
|
||||
|
||||
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
|
||||
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
|
||||
if (!empty($data['medical_system_code'])) $doctor->setMedicalSystemCode($data['medical_system_code']);
|
||||
if (!empty($data['info'])) $doctor->setInfo($data['info']);
|
||||
if (!empty($data['mobile_number'])) $doctor->setMobileNumber($data['mobile_number']);
|
||||
|
||||
if (!empty($data['specialties']) && is_array($data['specialties'])) {
|
||||
foreach ($data['specialties'] as $id) {
|
||||
$s = $this->em->getRepository(Specialty::class)->find((int) $id);
|
||||
if ($s !== null) $doctor->getSpecialties()->add($s);
|
||||
}
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$roles[] = 'ROLE_DOCTOR';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['uuid' => $doctor->getUuid()], 201);
|
||||
}
|
||||
|
||||
// ── Appointments ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -206,6 +206,7 @@ class Doctor
|
||||
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
|
||||
'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
|
||||
], $this->specialties->toArray()),
|
||||
'active' => $this->activeDoctorAppointment,
|
||||
'img' => $this->images ?? [],
|
||||
'expertise' => array_map(fn(DoctorService $ds) => [
|
||||
'uuid' => $ds->getUuid(), 'id' => (string) $ds->getId(), 'name' => $ds->getName(),
|
||||
|
||||
@@ -1189,6 +1189,11 @@
|
||||
resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz"
|
||||
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
|
||||
|
||||
"@react-leaflet/core@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz"
|
||||
integrity sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==
|
||||
|
||||
"@reduxjs/toolkit@^1.9.0 || 2.x.x":
|
||||
version "2.12.0"
|
||||
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz"
|
||||
@@ -1493,6 +1498,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz"
|
||||
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
|
||||
|
||||
"@types/geojson@*":
|
||||
version "7946.0.16"
|
||||
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz"
|
||||
integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==
|
||||
|
||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.6":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
|
||||
@@ -1517,6 +1527,13 @@
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
|
||||
"@types/leaflet@^1.9.21":
|
||||
version "1.9.21"
|
||||
resolved "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz"
|
||||
integrity sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==
|
||||
dependencies:
|
||||
"@types/geojson" "*"
|
||||
|
||||
"@types/node@*", "@types/node@^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node@^20.19.0 || >=22.12.0":
|
||||
version "25.9.2"
|
||||
resolved "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz"
|
||||
@@ -2921,6 +2938,11 @@ kind-of@^6.0.2:
|
||||
resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz"
|
||||
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
|
||||
|
||||
leaflet@^1.9.0, leaflet@^1.9.4:
|
||||
version "1.9.4"
|
||||
resolved "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz"
|
||||
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
|
||||
|
||||
lightningcss-darwin-arm64@^1.32.0, lightningcss-darwin-arm64@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz"
|
||||
@@ -3562,6 +3584,13 @@ react-is@^17.0.1:
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz"
|
||||
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||
|
||||
react-leaflet@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz"
|
||||
integrity sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==
|
||||
dependencies:
|
||||
"@react-leaflet/core" "^3.0.0"
|
||||
|
||||
"react-redux@^7.2.1 || ^8.1.3 || ^9.0.0", "react-redux@8.x.x || 9.x.x":
|
||||
version "9.3.0"
|
||||
resolved "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user