Files
clinicpro/assets/admin/pages/DoctorFormPage.tsx
T

341 lines
15 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; parent_id: number | null }
// ── 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 [activeParentId, setActiveParentId] = useState<number | null>(null);
const parents = useMemo(
() => specialties.filter(s => s.parent_id === null),
[specialties]
);
const children = useMemo(
() => activeParentId !== null ? specialties.filter(s => s.parent_id === activeParentId) : [],
[specialties, activeParentId]
);
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">
{/* Selected chips */}
{selected.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1 bg-slate-50 dark:bg-gray-800/40">
{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="flex" style={{ minHeight: 200 }}>
{/* Parents column */}
<div className="w-1/2 border-l border-slate-100 dark:border-gray-700 overflow-y-auto max-h-64">
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-gray-800/60 border-b border-slate-100 dark:border-gray-700">
گروه تخصص
</div>
{parents.map(p => (
<button
key={p.id}
type="button"
onClick={() => setActiveParentId(p.id)}
className="w-full text-right flex items-center gap-2 px-3 py-2 text-sm transition-colors"
style={{
background: activeParentId === p.id ? 'var(--primary-50, #eff6ff)' : 'transparent',
color: activeParentId === p.id ? 'var(--primary-700, #1d4ed8)' : undefined,
fontWeight: activeParentId === p.id ? 600 : 400,
}}
>
<span className="flex-1">{p.name}</span>
{specialties.some(s => s.parent_id === p.id && selected.includes(s.id)) && (
<span className="w-2 h-2 rounded-full bg-primary-500 shrink-0" />
)}
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
</button>
))}
</div>
{/* Children column */}
<div className="w-1/2 overflow-y-auto max-h-64">
{activeParentId === null ? (
<div className="flex items-center justify-center h-full text-xs text-slate-400 dark:text-slate-500 text-center px-4">
یک گروه تخصص را از سمت راست انتخاب کنید
</div>
) : children.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-slate-400 dark:text-slate-500">
زیرمجموعه‌ای یافت نشد
</div>
) : (
<>
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-gray-800/60 border-b border-slate-100 dark:border-gray-700">
انتخاب تخصص
</div>
{children.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>
))}
</>
)}
</div>
</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>
);
}