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

440 lines
22 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, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ArrowRightIcon, UserPlusIcon, PhoneIcon,
IdentificationIcon, AcademicCapIcon, DocumentTextIcon,
HeartIcon, ChevronLeftIcon, CheckIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import SearchableSelect from '../components/ui/SearchableSelect';
import MobileInput from '../components/ui/MobileInput';
import { iranMobileSchema } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
// ── Types ────────────────────────────────────────────────────────────────────
interface SpecialtyOption { id: number; uuid: string; name: string; parent_id: number | null }
// ── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({
mobile: iranMobileSchema,
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('')),
});
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: 'مرد', icon: '♂' },
{ value: 'woman', label: 'زن', icon: '♀' },
];
// ── Field ─────────────────────────────────────────────────────────────────────
function Field({ label, required, error, hint, children }: {
label: string; required?: boolean; error?: string; hint?: string; children: React.ReactNode;
}) {
return (
<div>
<label className="cp-label">
{label}{required && <span style={{ color: 'var(--danger)', marginRight: 2 }}>*</span>}
</label>
{children}
{hint && !error && <p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 5 }}>{hint}</p>}
{error && <p style={{ fontSize: 12, color: 'var(--danger)', marginTop: 5 }}>{error}</p>}
</div>
);
}
// ── Specialty Picker ──────────────────────────────────────────────────────────
//
// منطق انتخاب:
// • تخصص با فرزند → کلیک روی فرزند: parent_id + child_id هر دو ذخیره می‌شوند
// در هر گروه فقط یک فرزند قابل انتخاب است (جایگزین می‌شود)
// • تخصص بدون فرزند → فقط خودش انتخاب می‌شود
// • حذف chip: parent + child هر دو پاک می‌شوند
interface SelectedEntry { parentId: number | null; childId: number }
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 childMap = useMemo(() => {
const m: Record<number, SpecialtyOption[]> = {};
specialties.forEach(s => {
if (s.parent_id !== null) {
if (!m[s.parent_id]) m[s.parent_id] = [];
m[s.parent_id].push(s);
}
});
return m;
}, [specialties]);
const activeChildren = activeParentId !== null ? (childMap[activeParentId] ?? []) : [];
// برای هر گروه، child انتخاب‌شده را پیدا می‌کند
const selectedChildOfParent = (parentId: number): number | null => {
const kids = childMap[parentId] ?? [];
return kids.find(k => selected.includes(k.id))?.id ?? null;
};
// آیا یک والد بدون فرزند انتخاب شده (مستقیم)
const isRootSelected = (parentId: number): boolean =>
!(childMap[parentId]?.length) && selected.includes(parentId);
const selectChild = (child: SpecialtyOption) => {
const parentId = child.parent_id!;
if (selected.includes(child.id)) {
onChange([]);
} else {
onChange([parentId, child.id]);
}
};
const selectRoot = (root: SpecialtyOption) => {
if (selected.includes(root.id)) {
onChange([]);
} else {
setActiveParentId(null);
onChange([root.id]);
}
};
const removeEntry = () => { onChange([]); setActiveParentId(null); };
// ساخت chip ها: از selected فقط child‌ها + root‌های مستقیم را نمایش بده
const chips: SelectedEntry[] = useMemo(() => {
return selected
.map(id => {
const s = specialties.find(x => x.id === id);
if (!s) return null;
if (s.parent_id !== null) return { parentId: s.parent_id, childId: s.id };
// اگر parent است و child‌ای از آن انتخاب نشده → root مستقیم
const hasSelectedChild = (childMap[s.id] ?? []).some(k => selected.includes(k.id));
if (!hasSelectedChild && !(childMap[s.id]?.length)) return { parentId: null, childId: s.id };
return null; // parent‌هایی که فرزند دارند و فرزندشان انتخاب شده: در chip فرزند نمایش داده می‌شوند
})
.filter(Boolean) as SelectedEntry[];
}, [selected, specialties, childMap]);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{/* Selected chips */}
{chips.length > 0 && (
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{chips.map(({ parentId, childId }) => {
const child = specialties.find(x => x.id === childId);
const parent = parentId !== null ? specialties.find(x => x.id === parentId) : null;
if (!child) return null;
const label = parent ? `${parent.name}${child.name}` : child.name;
return (
<span key={childId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, padding: '3px 10px 3px 6px', borderRadius: 999, background: 'var(--primary-soft2)', color: 'var(--primary-700)', fontWeight: 600 }}>
{label}
<button type="button" onClick={() => removeEntry()}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16, borderRadius: '50%', background: 'var(--primary-soft)', border: 'none', cursor: 'pointer', color: 'var(--primary-600)', padding: 0, fontSize: 12, fontWeight: 700 }}>×</button>
</span>
);
})}
</div>
)}
<div style={{ display: 'flex', minHeight: 240 }}>
{/* Parents column */}
<div style={{ width: '45%', borderLeft: '1px solid var(--border)', overflowY: 'auto', maxHeight: 300 }}>
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
گروه تخصصی
</div>
{parents.map(p => {
const hasChildren = (childMap[p.id]?.length ?? 0) > 0;
const childSel = hasChildren ? selectedChildOfParent(p.id) : null;
const rootSel = !hasChildren && isRootSelected(p.id);
const isMarked = childSel !== null || rootSel;
const isActive = activeParentId === p.id;
const hasSelection = selected.length > 0;
const isDisabled = hasSelection && !isMarked;
return (
<button
key={p.id}
type="button"
disabled={isDisabled}
onClick={() => hasChildren ? setActiveParentId(p.id) : selectRoot(p)}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', fontSize: 13, border: 'none',
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.35 : 1,
background: isActive ? 'var(--primary-soft)' : rootSel ? 'var(--primary-soft)' : 'transparent',
color: (isActive || rootSel) ? 'var(--primary-700)' : 'var(--text)',
fontWeight: (isActive || rootSel) ? 700 : 400,
borderRight: (isActive || rootSel) ? '3px solid var(--primary)' : '3px solid transparent',
transition: 'all .14s',
}}
>
<span style={{ flex: 1, lineHeight: 1.4 }}>{p.name}</span>
{isMarked && !rootSel && (
<span style={{ fontSize: 11, fontWeight: 700, background: 'var(--primary)', color: 'var(--on-primary)', borderRadius: 999, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
</span>
)}
{rootSel && <CheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />}
{hasChildren && <ChevronLeftIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />}
</button>
);
})}
</div>
{/* Children column */}
<div style={{ flex: 1, overflowY: 'auto', maxHeight: 300 }}>
{activeParentId === null ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13, gap: 8, padding: 24 }}>
<HeartIcon style={{ width: 32, height: 32, opacity: .3 }} />
<span>یک گروه تخصصی انتخاب کنید</span>
</div>
) : activeChildren.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13 }}>
زیرمجموعه‌ای یافت نشد
</div>
) : (
<>
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
انتخاب تخصص یک مورد
</div>
{activeChildren.map(s => {
const checked = selected.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => selectChild(s)}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', border: 'none', cursor: 'pointer',
background: checked ? 'var(--primary-soft)' : 'transparent',
borderBottom: '1px solid var(--border)',
transition: 'background .12s',
}}
>
<div style={{ width: 18, height: 18, borderRadius: '50%', border: `2px solid ${checked ? 'var(--primary)' : 'var(--border-2)'}`, background: checked ? 'var(--primary)' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'all .12s' }}>
{checked && <div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--on-primary)' }} />}
</div>
<span style={{ fontSize: 13, color: checked ? 'var(--primary-700)' : 'var(--text)', fontWeight: checked ? 600 : 400 }}>{s.name}</span>
</button>
);
})}
</>
)}
</div>
</div>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DoctorFormPage() {
const navigate = useNavigate();
const primaryRole = useAuthStore(s => s.primaryRole);
const createDoctorEndpoint =
primaryRole === 'representation' ? '/api/v1/representation/doctor' : '/api/v1/admin/doctors';
const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]);
const [gender, setGender] = useState<'man' | 'woman' | ''>('');
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, control, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
});
const createMut = useMutation({
mutationFn: (values: FormValues) =>
api.post<ApiResponse<{ uuid: string }>>(createDoctorEndpoint, {
mobile: values.mobile,
name: values.name,
gender: 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);
return (
<div style={{ maxWidth: 680, margin: '0 auto' }} className="animate-slide-up">
{/* Breadcrumb */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-3)', marginBottom: 20 }}>
<button onClick={() => navigate('/admin/doctors')} style={{ display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', fontSize: 13, padding: 0 }}>
<ArrowRightIcon style={{ width: 14, height: 14 }} />
پزشکان
</button>
<span>/</span>
<span style={{ color: 'var(--text-2)', fontWeight: 600 }}>افزودن پزشک جدید</span>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── Section: اطلاعات حساب ──────────────────────────────── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<SectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Field label="شماره موبایل" required error={errors.mobile?.message} hint="اگر کاربر با این شماره وجود دارد پروفایل به او متصل می‌شود">
<MobileInput hasError={!!errors.mobile} {...register('mobile')} />
</Field>
<Field label="نام کامل" required error={errors.name?.message}>
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')}
style={errors.name ? { borderColor: 'var(--danger)' } : {}} />
</Field>
</div>
</div>
{/* ── Section: اطلاعات حرفه‌ای ────────────────────────────── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<SectionHeader icon={<IdentificationIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حرفه‌ای" />
{/* Gender toggle */}
<div style={{ marginBottom: 14 }}>
<label className="cp-label">جنسیت</label>
<div style={{ display: 'flex', gap: 8 }}>
{GENDER_OPTIONS.map(o => (
<button key={o.value} type="button" onClick={() => setGender(gender === o.value ? '' : o.value as 'man' | 'woman')}
style={{
flex: 1, height: 42, borderRadius: 'var(--r-sm)', border: `1.5px solid ${gender === o.value ? 'var(--primary)' : 'var(--border)'}`,
background: gender === o.value ? 'var(--primary-soft)' : 'var(--surface)',
color: gender === o.value ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: gender === o.value ? 700 : 400, fontSize: 14, cursor: 'pointer', transition: 'all .14s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<span style={{ fontSize: 16 }}>{o.icon}</span>{o.label}
</button>
))}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Field label="درجه تحصیلی">
<Controller name="degree" control={control} render={({ field }) => (
<SearchableSelect
options={DEGREE_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</Field>
<Field label="کد نظام پزشکی">
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
</Field>
</div>
<div style={{ marginTop: 14 }}>
<Field label="بیوگرافی">
<textarea rows={3} className="cp-textarea" placeholder="معرفی کوتاهی از پزشک..." {...register('info')} style={{ resize: 'none' }} />
</Field>
</div>
</div>
{/* ── Section: تخصص‌ها ────────────────────────────────────── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<SectionHeader
icon={<AcademicCapIcon style={{ width: 16, height: 16 }} />}
title="تخصص‌ها"
badge={selectedSpecialties.length > 0 ? `${selectedSpecialties.length} انتخاب شده` : undefined}
/>
{specialtiesQ.isLoading ? (
<div style={{ height: 200, borderRadius: 'var(--r)', overflow: 'hidden' }} className="skeleton" />
) : (
<SpecialtyPicker
selected={selectedSpecialties}
onChange={setSelectedSpecialties}
specialties={specialties}
/>
)}
</div>
{/* ── Actions ─────────────────────────────────────────────── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10, paddingBottom: 8 }}>
<button type="button" onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary">
انصراف
</button>
<button type="submit" disabled={createMut.isPending} className="cp-btn-primary" style={{ minWidth: 140 }}>
{createMut.isPending ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 16, height: 16, border: '2px solid rgba(255,255,255,.3)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin .7s linear infinite', flexShrink: 0 }} />
در حال ذخیره...
</span>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<UserPlusIcon style={{ width: 16, height: 16 }} />
افزودن پزشک
</span>
)}
</button>
</div>
</div>
</form>
</div>
);
}
// ── Section Header ────────────────────────────────────────────────────────────
function SectionHeader({ icon, title, badge }: { icon: React.ReactNode; title: string; badge?: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, paddingBottom: 14, borderBottom: '1px solid var(--border)' }}>
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{icon}
</div>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{title}</span>
{badge && (
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--primary-700)', background: 'var(--primary-soft)', padding: '2px 10px', borderRadius: 999 }}>
{badge}
</span>
)}
</div>
);
}