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:
@@ -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,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}
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user