feat: enhance DoctorFormPage with searchable specialties and improved UI components

- Refactored DoctorFormPage to use Controller from react-hook-form for better form handling.
- Added a new Field component for consistent input styling and error handling.
- Implemented a SpecialtyPicker component with improved selection logic for specialties.
- Updated the layout and styling of the form sections for better user experience.
- Integrated SearchableSelect for selecting specialties and roles in DoctorsPage and UsersPage.
- Added createClinic API endpoint to handle clinic creation with validation for mobile and name fields.
This commit is contained in:
hamed
2026-06-12 20:43:47 +03:30
parent 9e8064e101
commit 960ff1ab29
10 changed files with 480 additions and 344 deletions
+38 -40
View File
@@ -17,6 +17,7 @@ interface Props {
isClearable?: boolean; isClearable?: boolean;
noOptionsMessage?: string; noOptionsMessage?: string;
inputId?: string; inputId?: string;
height?: number;
} }
export default function SearchableSelect({ export default function SearchableSelect({
@@ -29,6 +30,7 @@ export default function SearchableSelect({
isClearable, isClearable,
noOptionsMessage = 'موردی یافت نشد', noOptionsMessage = 'موردی یافت نشد',
inputId, inputId,
height = 42,
}: Props) { }: Props) {
const darkMode = useUiStore((s) => s.darkMode); const darkMode = useUiStore((s) => s.darkMode);
@@ -40,64 +42,60 @@ export default function SearchableSelect({
const styles: StylesConfig<SelectOption, false, GroupBase<SelectOption>> = { const styles: StylesConfig<SelectOption, false, GroupBase<SelectOption>> = {
control: (base, state) => ({ control: (base, state) => ({
...base, ...base,
background: darkMode ? '#111827' : '#ffffff', background: darkMode ? 'var(--surface)' : 'var(--surface)',
borderColor: state.isFocused ? '#7c3aed' : darkMode ? '#4b5563' : '#cbd5e1', borderColor: state.isFocused ? 'var(--primary)' : 'var(--border)',
boxShadow: state.isFocused ? '0 0 0 2px rgba(124,58,237,0.25)' : 'none', boxShadow: state.isFocused ? '0 0 0 4px var(--ring)' : 'none',
borderRadius: '0.75rem', borderRadius: 'var(--r-sm)',
minHeight: '2.75rem', minHeight: height,
fontSize: '0.875rem', fontSize: 14,
cursor: 'pointer',
'&:hover': { '&:hover': {
borderColor: state.isFocused ? '#7c3aed' : darkMode ? '#6b7280' : '#94a3b8', borderColor: state.isFocused ? 'var(--primary)' : 'var(--border-2)',
}, },
}), }),
valueContainer: (base) => ({ ...base, padding: '0 14px' }),
menu: (base) => ({ menu: (base) => ({
...base, ...base,
background: darkMode ? '#1f2937' : '#ffffff', background: 'var(--surface)',
border: `1px solid ${darkMode ? '#374151' : '#e2e8f0'}`, border: '1px solid var(--border)',
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', boxShadow: 'var(--shadow-lg)',
borderRadius: '0.75rem', borderRadius: 'var(--r)',
overflow: 'hidden', overflow: 'hidden',
marginTop: 4,
}), }),
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (base, state) => ({ option: (base, state) => ({
...base, ...base,
background: state.isSelected background: state.isSelected
? '#7c3aed' ? 'var(--primary-soft2)'
: state.isFocused : state.isFocused
? darkMode ? '#374151' : '#f1f5f9' ? 'var(--surface-2)'
: 'transparent', : 'transparent',
color: state.isSelected ? '#ffffff' : darkMode ? '#e5e7eb' : '#1e293b', color: state.isSelected ? 'var(--primary-700)' : 'var(--text)',
fontSize: '0.875rem', fontWeight: state.isSelected ? 700 : 400,
cursor: 'pointer', fontSize: 14,
cursor: 'pointer',
padding: '9px 14px',
}), }),
singleValue: (base) => ({ ...base, color: darkMode ? '#f3f4f6' : '#1e293b' }), singleValue: (base) => ({ ...base, color: 'var(--text)' }),
input: (base) => ({ ...base, color: darkMode ? '#f3f4f6' : '#1e293b' }), input: (base) => ({ ...base, color: 'var(--text)', margin: 0, padding: 0 }),
placeholder: (base) => ({ ...base, color: darkMode ? '#6b7280' : '#94a3b8' }), placeholder: (base) => ({ ...base, color: 'var(--text-3)' }),
indicatorSeparator: (base) => ({ indicatorSeparator: (base) => ({ ...base, background: 'var(--border)' }),
dropdownIndicator: (base) => ({
...base, ...base,
background: darkMode ? '#374151' : '#e2e8f0', color: 'var(--text-3)',
}), padding: '0 10px',
dropdownIndicator: (base) => ({ '&:hover': { color: 'var(--text-2)' },
...base,
color: darkMode ? '#6b7280' : '#94a3b8',
'&:hover': { color: darkMode ? '#9ca3af' : '#64748b' },
}), }),
clearIndicator: (base) => ({ clearIndicator: (base) => ({
...base, ...base,
color: darkMode ? '#6b7280' : '#94a3b8', color: 'var(--text-3)',
'&:hover': { color: '#ef4444' }, padding: '0 6px',
}), '&:hover': { color: 'var(--danger)' },
loadingIndicator: (base) => ({ ...base, color: '#7c3aed' }),
noOptionsMessage: (base) => ({
...base,
color: darkMode ? '#6b7280' : '#94a3b8',
fontSize: '0.875rem',
}),
loadingMessage: (base) => ({
...base,
color: darkMode ? '#6b7280' : '#94a3b8',
fontSize: '0.875rem',
}), }),
loadingIndicator: (base) => ({ ...base, color: 'var(--primary)' }),
noOptionsMessage: (base) => ({ ...base, color: 'var(--text-3)', fontSize: 13 }),
loadingMessage: (base) => ({ ...base, color: 'var(--text-3)', fontSize: 13 }),
}; };
return ( return (
+10 -10
View File
@@ -10,6 +10,7 @@ import { formatDate, formatDateTime } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
import StatusBadge from '../components/ui/StatusBadge'; import StatusBadge from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [ const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [
{ value: 'pending', label: 'رزرو شده' }, { value: 'pending', label: 'رزرو شده' },
@@ -112,16 +113,15 @@ export default function AppointmentDetailPage() {
<div className="mt-6"> <div className="mt-6">
<label className="cp-label mb-2">تغییر وضعیت:</label> <label className="cp-label mb-2">تغییر وضعیت:</label>
<div className="flex gap-2"> <div className="flex gap-2">
<select <div style={{ flex: 1 }}>
value={newStatus} <SearchableSelect
onChange={(e) => setNewStatus(e.target.value)} options={ALL_STATUSES.map(s => ({ value: s.value, label: s.label }))}
className="cp-input flex-1" value={newStatus || null}
> onChange={(v) => setNewStatus(v ? String(v) : '')}
<option value="">انتخاب وضعیت...</option> placeholder="انتخاب وضعیت..."
{ALL_STATUSES.map((s) => ( isClearable
<option key={s.value} value={s.value}>{s.label}</option> />
))} </div>
</select>
<button <button
onClick={() => newStatus && statusMutation.mutate(newStatus)} onClick={() => newStatus && statusMutation.mutate(newStatus)}
disabled={!newStatus || statusMutation.isPending} disabled={!newStatus || statusMutation.isPending}
+11 -14
View File
@@ -12,6 +12,7 @@ import { formatDate, toGregorianDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown'; import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
import PersianCalendar from '../components/ui/PersianCalendar'; import PersianCalendar from '../components/ui/PersianCalendar';
import SearchableSelect from '../components/ui/SearchableSelect';
const EMPTY_ARR: Appointment[] = []; const EMPTY_ARR: Appointment[] = [];
@@ -704,20 +705,16 @@ export default function AppointmentsPage() {
{/* Doctor selector (admin / clinic) */} {/* Doctor selector (admin / clinic) */}
{!isDoctor && ( {!isDoctor && (
<select <div style={{ minWidth: 200 }}>
value={selectedDoctorUuid} <SearchableSelect
onChange={e => setSelectedDoctorUuid(e.target.value)} options={doctors.map(d => ({ value: d.uuid, label: d.name }))}
style={{ value={selectedDoctorUuid || null}
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)', onChange={(v) => setSelectedDoctorUuid(v ? String(v) : '')}
border: '1px solid var(--border)', background: 'var(--surface)', placeholder="انتخاب پزشک..."
fontSize: 13, color: 'var(--text)', minWidth: 180, cursor: 'pointer', isClearable
}} height={36}
> />
<option value="">پرسنل را انتخاب کنید...</option> </div>
{doctors.map(d => (
<option key={d.uuid} value={d.uuid}>{d.name}</option>
))}
</select>
)} )}
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
+9 -5
View File
@@ -9,6 +9,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import type { Blog } from '../types'; import type { Blog } from '../types';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
const schema = z.object({ const schema = z.object({
title: z.string().min(3, 'عنوان الزامی است'), title: z.string().min(3, 'عنوان الزامی است'),
@@ -134,11 +135,14 @@ export default function BlogFormPage() {
control={control} control={control}
name="status" name="status"
render={({ field }) => ( render={({ field }) => (
<select {...field} <SearchableSelect
className="cp-input h-11"> options={[
<option value="draft">پیشنویس</option> { value: 'draft', label: 'پیش‌نویس' },
<option value="published">منتشر</option> { value: 'published', label: 'منتشر شده' },
</select> ]}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? 'draft')}
/>
)} )}
/> />
</div> </div>
+50 -42
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { import {
@@ -25,6 +25,7 @@ import { formatNumber } from '../lib/utils';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard'; import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import GlobalSearchableSelect from '../components/ui/SearchableSelect';
// Fix leaflet default marker icons // Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl; delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -996,10 +997,14 @@ function SlotEditor({ slots, onChange }: {
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">تا</span> <span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">تا</span>
<input type="time" value={slot.end} onChange={e => update(i, 'end', e.target.value)} <input type="time" value={slot.end} onChange={e => update(i, 'end', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" /> className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<select value={slot.duration} onChange={e => update(i, 'duration', Number(e.target.value))} <div style={{ width: 110 }}>
className="cp-select h-8 text-sm w-24"> <GlobalSearchableSelect
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)} options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
</select> value={slot.duration}
onChange={(v) => update(i, 'duration', Number(v))}
height={32}
/>
</div>
<button type="button" onClick={() => remove(i)} <button type="button" onClick={() => remove(i)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors shrink-0"> className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors shrink-0">
<XMarkIcon className="w-3.5 h-3.5" /> <XMarkIcon className="w-3.5 h-3.5" />
@@ -1052,37 +1057,30 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}> <div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div> <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
<div className="field"> <GlobalSearchableSelect
<select value={session.duration_per_patient} options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
onChange={e => upd('duration_per_patient', Number(e.target.value))}> value={session.duration_per_patient}
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)} onChange={(v) => upd('duration_per_patient', Number(v))}
</select> />
</div>
</div> </div>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div> <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div>
<div className="field"> <GlobalSearchableSelect
<select value={session.location_id ?? ''} options={[
onChange={e => upd('location_id', e.target.value ? Number(e.target.value) : null)}> ...addresses.filter(a => a.type === 'personal').map(a => ({
<option value="">انتخاب کنید</option> value: Number(a.id),
{addresses.filter(a => a.type === 'personal').length > 0 && ( label: `🏥 ${a.name ?? a.address ?? `مطب ${a.id}`}`,
<optgroup label="مطب شخصی"> })),
{addresses.filter(a => a.type === 'personal').map(a => ( ...addresses.filter(a => a.type === 'clinic').map(a => ({
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option> value: Number(a.id),
))} label: `🏨 ${a.clinic_name ? `${a.clinic_name}${a.name ? `${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}`,
</optgroup> })),
)} ]}
{addresses.filter(a => a.type === 'clinic').length > 0 && ( value={session.location_id ?? null}
<optgroup label="کلینیک‌ها"> onChange={(v) => upd('location_id', v ? Number(v) : null)}
{addresses.filter(a => a.type === 'clinic').map(a => ( placeholder="انتخاب مکان"
<option key={a.id} value={a.id}> isClearable
{a.clinic_name ? `${a.clinic_name}${a.name ? `${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)} />
</option>
))}
</optgroup>
)}
</select>
</div>
</div> </div>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>حداکثر نوبت</div> <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>حداکثر نوبت</div>
@@ -1850,7 +1848,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
// ── Edit form ── // ── Edit form ──
const { register, handleSubmit, reset, watch, setValue } = useForm<EditForm>({ const { register, handleSubmit, reset, watch, setValue, control: editControl } = useForm<EditForm>({
resolver: zodResolver(editSchema), resolver: zodResolver(editSchema),
}); });
const watchedSpecialties = (watch('specialties') ?? []) as number[]; const watchedSpecialties = (watch('specialties') ?? []) as number[];
@@ -2279,17 +2277,27 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">جنسیت</label> <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')}> <Controller name="gender" control={editControl} render={({ field }) => (
<option value="">انتخاب کنید</option> <GlobalSearchableSelect
{GENDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} options={GENDER_OPTIONS}
</select> value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">درجه تحصیلی</label> <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')}> <Controller name="degree" control={editControl} render={({ field }) => (
<option value="">انتخاب کنید</option> <GlobalSearchableSelect
{DEGREE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} options={DEGREE_OPTIONS}
</select> value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">کد نظام پزشکی</label> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">کد نظام پزشکی</label>
+304 -210
View File
@@ -1,20 +1,24 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { ArrowRightIcon, UserPlusIcon } from '@heroicons/react/24/outline'; import {
ArrowRightIcon, UserPlusIcon, PhoneIcon,
IdentificationIcon, AcademicCapIcon, DocumentTextIcon,
HeartIcon, ChevronLeftIcon, CheckIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import { formatNumber } from '../lib/utils'; import SearchableSelect from '../components/ui/SearchableSelect';
// ── Types ──────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
interface SpecialtyOption { id: number; uuid: string; name: string; parent_id: number | null } interface SpecialtyOption { id: number; uuid: string; name: string; parent_id: number | null }
// ── Schema ─────────────────────────────────────────────────────────────── // ── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({ const schema = z.object({
mobile: z.string().min(10, 'شماره موبایل معتبر نیست').max(15), mobile: z.string().min(10, 'شماره موبایل معتبر نیست').max(15),
@@ -23,25 +27,49 @@ const schema = z.object({
degree: z.string().optional().or(z.literal('')), degree: z.string().optional().or(z.literal('')),
medical_system_code: z.string().max(30).optional().or(z.literal('')), medical_system_code: z.string().max(30).optional().or(z.literal('')),
info: z.string().max(2000).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>; type FormValues = z.infer<typeof schema>;
// ── Constants ───────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
const DEGREE_OPTIONS = [ const DEGREE_OPTIONS = [
{ value: 'general', label: 'عمومی' }, { value: 'general', label: 'پزشک عمومی' },
{ value: 'specialist', label: 'متخصص' }, { value: 'specialist', label: 'متخصص' },
{ value: 'expert', label: 'فوق تخصص' }, { value: 'expert', label: 'فوق تخصص' },
{ value: 'subspecialistplus', label: 'فلوشیپ' }, { value: 'subspecialistplus', label: 'فلوشیپ' },
]; ];
const GENDER_OPTIONS = [ const GENDER_OPTIONS = [
{ value: 'man', label: 'مرد' }, { value: 'man', label: 'مرد', icon: '♂' },
{ value: 'woman', label: 'زن' }, { value: 'woman', label: 'زن', icon: '♀' },
]; ];
// ── Specialty Picker ────────────────────────────────────────────────────── // ── 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 }: { function SpecialtyPicker({ selected, onChange, specialties }: {
selected: number[]; onChange: (ids: number[]) => void; selected: number[]; onChange: (ids: number[]) => void;
@@ -49,90 +77,170 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
}) { }) {
const [activeParentId, setActiveParentId] = useState<number | null>(null); const [activeParentId, setActiveParentId] = useState<number | null>(null);
const parents = useMemo( const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]);
() => specialties.filter(s => s.parent_id === null), const childMap = useMemo(() => {
[specialties] 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 children = useMemo( const activeChildren = activeParentId !== null ? (childMap[activeParentId] ?? []) : [];
() => activeParentId !== null ? specialties.filter(s => s.parent_id === activeParentId) : [],
[specialties, activeParentId]
);
const toggle = (id: number) => // برای هر گروه، child انتخاب‌شده را پیدا می‌کند
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); 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 ( return (
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden"> <div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{/* Selected chips */} {/* Selected chips */}
{selected.length > 0 && ( {chips.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"> <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{selected.map(id => { {chips.map(({ parentId, childId }) => {
const s = specialties.find(x => x.id === id); const child = specialties.find(x => x.id === childId);
if (!s) return null; 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 ( 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"> <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 }}>
{s.name} {label}
<button type="button" onClick={() => toggle(id)} className="hover:text-primary-900 dark:hover:text-primary-100 leading-none">×</button> <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> </span>
); );
})} })}
</div> </div>
)} )}
<div className="flex" style={{ minHeight: 200 }}> <div style={{ display: 'flex', minHeight: 240 }}>
{/* Parents column */} {/* Parents column */}
<div className="w-1/2 border-l border-slate-100 dark:border-gray-700 overflow-y-auto max-h-64"> <div style={{ width: '45%', borderLeft: '1px solid var(--border)', overflowY: 'auto', maxHeight: 300 }}>
<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 style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
گروه تخصص گروه تخصصی
</div> </div>
{parents.map(p => ( {parents.map(p => {
<button const hasChildren = (childMap[p.id]?.length ?? 0) > 0;
key={p.id} const childSel = hasChildren ? selectedChildOfParent(p.id) : null;
type="button" const rootSel = !hasChildren && isRootSelected(p.id);
onClick={() => setActiveParentId(p.id)} const isMarked = childSel !== null || rootSel;
className="w-full text-right flex items-center gap-2 px-3 py-2 text-sm transition-colors" const isActive = activeParentId === p.id;
style={{ const hasSelection = selected.length > 0;
background: activeParentId === p.id ? 'var(--primary-50, #eff6ff)' : 'transparent', const isDisabled = hasSelection && !isMarked;
color: activeParentId === p.id ? 'var(--primary-700, #1d4ed8)' : undefined, return (
fontWeight: activeParentId === p.id ? 600 : 400, <button
}} key={p.id}
> type="button"
<span className="flex-1">{p.name}</span> disabled={isDisabled}
{specialties.some(s => s.parent_id === p.id && selected.includes(s.id)) && ( onClick={() => hasChildren ? setActiveParentId(p.id) : selectRoot(p)}
<span className="w-2 h-2 rounded-full bg-primary-500 shrink-0" /> style={{
)} width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 8,
<span className="text-slate-300 dark:text-slate-600 text-xs"></span> padding: '10px 14px', fontSize: 13, border: 'none',
</button> 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> </div>
{/* Children column */} {/* Children column */}
<div className="w-1/2 overflow-y-auto max-h-64"> <div style={{ flex: 1, overflowY: 'auto', maxHeight: 300 }}>
{activeParentId === null ? ( {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 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> </div>
) : children.length === 0 ? ( ) : activeChildren.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-slate-400 dark:text-slate-500"> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13 }}>
زیرمجموعهای یافت نشد زیرمجموعهای یافت نشد
</div> </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 style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
انتخاب تخصص انتخاب تخصص یک مورد
</div> </div>
{children.map(s => ( {activeChildren.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"> const checked = selected.includes(s.id);
<input return (
type="checkbox" <button
checked={selected.includes(s.id)} key={s.id}
onChange={() => toggle(s.id)} type="button"
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0" onClick={() => selectChild(s)}
/> style={{
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span> width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 10,
</label> 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>
@@ -141,11 +249,12 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
); );
} }
// ── Page ────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
export default function DoctorFormPage() { export default function DoctorFormPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]); const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]);
const [gender, setGender] = useState<'man' | 'woman' | ''>('');
const specialtiesQ = useQuery({ const specialtiesQ = useQuery({
queryKey: ['specialties-list'], queryKey: ['specialties-list'],
@@ -155,12 +264,11 @@ export default function DoctorFormPage() {
const specialties: SpecialtyOption[] = useMemo( const specialties: SpecialtyOption[] = useMemo(
() => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [], () => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [],
[specialtiesQ.data] [specialtiesQ.data],
); );
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({ const { register, handleSubmit, control, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { specialties: [] },
}); });
const createMut = useMutation({ const createMut = useMutation({
@@ -168,7 +276,7 @@ export default function DoctorFormPage() {
api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/doctors', { api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/doctors', {
mobile: values.mobile, mobile: values.mobile,
name: values.name, name: values.name,
gender: values.gender || undefined, gender: gender || undefined,
degree: values.degree || undefined, degree: values.degree || undefined,
medical_system_code: values.medical_system_code || undefined, medical_system_code: values.medical_system_code || undefined,
info: values.info || undefined, info: values.info || undefined,
@@ -183,158 +291,144 @@ export default function DoctorFormPage() {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
const onSubmit = (values: FormValues) => createMut.mutate({ ...values, specialties: selectedSpecialties }); const onSubmit = (values: FormValues) => createMut.mutate(values);
return ( return (
<div className="animate-slide-up max-w-3xl mx-auto space-y-5"> <div style={{ maxWidth: 680, margin: '0 auto' }} className="animate-slide-up">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-3)', marginBottom: 20 }}>
<button onClick={() => navigate('/admin/doctors')} <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 }}>
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors"> <ArrowRightIcon style={{ width: 14, height: 14 }} />
<ArrowRightIcon className="w-4 h-4" />پزشکان پزشکان
</button> </button>
<span>/</span> <span>/</span>
<span className="text-slate-700 dark:text-slate-300 font-medium">افزودن پزشک جدید</span> <span style={{ color: 'var(--text-2)', fontWeight: 600 }}>افزودن پزشک جدید</span>
</div> </div>
{/* Header card */} <form onSubmit={handleSubmit(onSubmit)}>
<div className="cp-card p-6 flex items-center gap-4"> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<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 */} {/* ── Section: اطلاعات حساب ──────────────────────────────── */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5"> <div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<SectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
{/* Account info */} <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<div className="cp-card p-6 space-y-4"> <Field label="شماره موبایل" required error={errors.mobile?.message} hint="اگر کاربر با این شماره وجود دارد پروفایل به او متصل می‌شود">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3"> <input type="tel" dir="ltr" className="cp-input" placeholder="09xxxxxxxxx" {...register('mobile')}
اطلاعات حساب style={errors.mobile ? { borderColor: 'var(--danger)' } : {}} />
</h2> </Field>
<Field label="نام کامل" required error={errors.name?.message}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')}
{/* Mobile */} style={errors.name ? { borderColor: 'var(--danger)' } : {}} />
<div> </Field>
<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>
</div> </div>
{/* Bio */} {/* ── Section: اطلاعات حرفه‌ای ────────────────────────────── */}
<div> <div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بیوگرافی</label> <SectionHeader icon={<IdentificationIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حرفه‌ای" />
<textarea rows={4}
className="cp-input resize-none"
placeholder="معرفی کوتاهی از پزشک..."
{...register('info')} />
</div>
</div>
{/* Specialties */} {/* Gender toggle */}
<div className="cp-card p-6 space-y-3"> <div style={{ marginBottom: 14 }}>
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3"> <label className="cp-label">جنسیت</label>
تخصصها <div style={{ display: 'flex', gap: 8 }}>
{selectedSpecialties.length > 0 && ( {GENDER_OPTIONS.map(o => (
<span className="text-xs font-normal text-primary-600 dark:text-primary-400 mr-2"> <button key={o.value} type="button" onClick={() => setGender(gender === o.value ? '' : o.value as 'man' | 'woman')}
{formatNumber(selectedSpecialties.length)} انتخاب شده style={{
</span> 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)',
</h2> color: gender === o.value ? 'var(--primary-700)' : 'var(--text-2)',
{specialtiesQ.isLoading ? ( fontWeight: gender === o.value ? 700 : 400, fontSize: 14, cursor: 'pointer', transition: 'all .14s',
<div className="h-32 rounded-xl skeleton" /> display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
) : ( }}>
<SpecialtyPicker <span style={{ fontSize: 16 }}>{o.icon}</span>{o.label}
selected={selectedSpecialties} </button>
onChange={setSelectedSpecialties} ))}
specialties={specialties} </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> <div style={{ height: 200, borderRadius: 'var(--r)', overflow: 'hidden' }} className="skeleton" />
{/* 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"> <SpecialtyPicker
<UserPlusIcon className="w-4 h-4" />افزودن پزشک selected={selectedSpecialties}
</span> onChange={setSelectedSpecialties}
specialties={specialties}
/>
)} )}
</button> </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> </div>
</form> </form>
</div> </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>
);
}
+10 -7
View File
@@ -12,6 +12,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber } from '../lib/utils'; import { formatDate, formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
// ── Types ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
@@ -227,13 +228,15 @@ export default function DoctorsPage() {
/> />
</div> </div>
{specialties.length > 0 && ( {specialties.length > 0 && (
<div className="field"> <div style={{ minWidth: 180 }}>
<select value={specialtyId} onChange={(e) => setSpecialty(e.target.value)}> <SearchableSelect
<option value="">همه تخصصها</option> options={specialties.map(s => ({ value: String(s.id), label: s.name }))}
{specialties.map((s) => ( value={specialtyId || null}
<option key={s.id} value={String(s.id)}>{s.name}</option> onChange={(v) => setSpecialty(v ? String(v) : '')}
))} placeholder="همه تخصص‌ها"
</select> isClearable
height={36}
/>
</div> </div>
)} )}
<div className="seg"> <div className="seg">
@@ -11,6 +11,7 @@ import PageHeader from '../components/ui/PageHeader';
import { ActiveBadge } from '../components/ui/StatusBadge'; import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return ( return (
@@ -244,16 +245,13 @@ export default function RepresentationDetailPage() {
</div> </div>
<div> <div>
<label className="">شهر</label> <label className="">شهر</label>
<select <SearchableSelect
value={formData.city_id} options={cities.map(c => ({ value: String(c.id), label: c.name }))}
onChange={(e) => setFormData((p) => ({ ...p, city_id: e.target.value }))} value={formData.city_id || null}
className="cp-input h-11" onChange={(v) => setFormData(p => ({ ...p, city_id: v ? String(v) : '' }))}
> placeholder="انتخاب شهر"
<option value="">انتخاب شهر</option> isClearable
{cities.map((c) => ( />
<option key={c.id} value={String(c.id)}>{c.name}</option>
))}
</select>
</div> </div>
<div> <div>
<label className="">موبایل</label> <label className="">موبایل</label>
+8 -6
View File
@@ -12,6 +12,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, maskMobile } from '../lib/utils'; import { formatDate, formatNumber, maskMobile } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from '@heroicons/react/24/outline';
// ── Types ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
@@ -292,12 +293,13 @@ export default function UsersPage() {
placeholder="نام، موبایل یا ایمیل..." placeholder="نام، موبایل یا ایمیل..."
/> />
</div> </div>
<div className="field"> <div style={{ minWidth: 160 }}>
<select value={role} onChange={(e) => setRole(e.target.value)}> <SearchableSelect
{ROLE_TABS.map((t) => ( options={ROLE_TABS.map(t => ({ value: t.key, label: t.label }))}
<option key={t.key} value={t.key}>{t.label}</option> value={role || ''}
))} onChange={(v) => setRole(v ? String(v) : '')}
</select> height={36}
/>
</div> </div>
<div className="seg"> <div className="seg">
<button className={!status ? 'on' : ''} onClick={() => setStatus('')}>همه</button> <button className={!status ? 'on' : ''} onClick={() => setStatus('')}>همه</button>
@@ -484,6 +484,38 @@ class AdminApiController extends BaseController
return $this->paginated($items, $total, $page, $limit); return $this->paginated($items, $total, $page, $limit);
} }
#[Route('/api/v1/admin/clinic', methods: ['POST'])]
public function createClinic(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim((string) ($data['owner_mobile'] ?? ''));
$name = trim((string) ($data['name'] ?? ''));
if ($mobile === '') {
return $this->error('VALIDATION', 'شماره موبایل الزامی است', 422);
}
if ($name === '') {
return $this->error('VALIDATION', 'نام کلینیک الزامی است', 422);
}
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$user) {
$user = new User($mobile);
$this->em->persist($user);
}
$clinic = new Clinic($user);
$clinic->setName($name);
if (!empty($data['telephone'])) $clinic->setTelephone($data['telephone']);
if (!empty($data['address'])) $clinic->setAddress($data['address']);
if (!empty($data['info'])) $clinic->setInfo($data['info']);
$this->em->persist($clinic);
$this->em->flush();
return $this->success(['uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), 'is_active' => $clinic->isActive()]);
}
#[Route('/api/v1/admin/clinic/{uuid}/status', methods: ['PATCH'])] #[Route('/api/v1/admin/clinic/{uuid}/status', methods: ['PATCH'])]
public function toggleClinicStatus(string $uuid): JsonResponse public function toggleClinicStatus(string $uuid): JsonResponse
{ {