Files
clinicpro/assets/admin/pages/DoctorFormPage.tsx
T
hamed 25701c09b7 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.
2026-06-10 17:02:42 +03:30

288 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}