@@ -10,7 +10,7 @@ import {
XCircleIcon , PhoneIcon , CalendarIcon ,
ClipboardDocumentIcon , EllipsisVerticalIcon , StarIcon ,
BuildingOfficeIcon , MapPinIcon , AcademicCapIcon , UserIcon ,
PlusIcon , CameraIcon , ChevronDownIcon , ChevronRightIcon ,
PlusIcon , CameraIcon , ChevronDownIcon , ChevronRightIcon , ChevronLeftIcon ,
CheckCircleIcon , XMarkIcon ,
} from '@heroicons/react/24/outline' ;
import { StarIcon as StarSolid } from '@heroicons/react/24/solid' ;
@@ -18,7 +18,7 @@ import { toast } from 'sonner';
import { MapContainer , TileLayer , Marker , useMapEvents , useMap } from 'react-leaflet' ;
import L from 'leaflet' ;
import 'leaflet/dist/leaflet.css' ;
import { api } from '../lib/api' ;
import { api , ApiError } from '../lib/api' ;
import { useAuthStore } from '../stores/authStore' ;
import type { ApiResponse } from '../lib/api' ;
import { formatNumber } from '../lib/utils' ;
@@ -63,6 +63,15 @@ interface AddressData {
province : { id : string ; name : string } | null ;
}
// ── Schedule types ─────────────────────────────────────────────────────────
interface SlotConfig { start : string ; end : string ; duration : number ; }
interface DayConfig { active : boolean ; slots : SlotConfig [ ] ; }
type ScheduleMap = Record < string , DayConfig > ;
interface WeeklyScheduleData { uuid : string ; doctor_uuid : string ; schedule : ScheduleMap ; }
interface DateOverrideData { uuid : string ; doctor_uuid : string ; date : number ; active : boolean ; reason : string | null ; custom_slots : SlotConfig [ ] ; }
interface HolidayData { uuid : string ; doctor_uuid : string ; start_date : number ; end_date : number ; active : boolean ; reason : string | null ; }
// ── Constants ──────────────────────────────────────────────────────────────
const DEGREE_LABELS : Record < string , string > = {
@@ -90,6 +99,175 @@ const SPECIALTY_COLORS = [
'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300' ,
] ;
// ── Persian (Jalali) date utilities ───────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jalaali = require ( 'jalaali-js' ) as {
toJalaali : ( gy : number , gm : number , gd : number ) = > { jy : number ; jm : number ; jd : number } ;
toGregorian : ( jy : number , jm : number , jd : number ) = > { gy : number ; gm : number ; gd : number } ;
jalaaliMonthLength : ( jy : number , jm : number ) = > number ;
} ;
const JALALI_MONTHS = [ 'فروردین' , 'اردیبهشت' , 'خرداد' , 'تیر' , 'مرداد' , 'شهریور' , 'مهر' , 'آبان' , 'آذر' , 'دی' , 'بهمن' , 'اسفند' ] ;
const JALALI_DAYS_SHORT = [ 'ش' , 'ی' , 'د' , 'س' , 'چ' , 'پ' , 'ج' ] ;
function toPersianNums ( n : number | string ) : string {
return String ( n ) . replace ( /\d/g , d = > '۰۱۲۳۴۵۶۷۸۹' [ parseInt ( d ) ] ) ;
}
function gToJ ( gDate : string ) : { jy : number ; jm : number ; jd : number } | null {
if ( ! gDate ) return null ;
const [ gy , gm , gd ] = gDate . split ( '-' ) . map ( Number ) ;
return jalaali . toJalaali ( gy , gm , gd ) ;
}
function jToG ( jy : number , jm : number , jd : number ) : string {
const { gy , gm , gd } = jalaali . toGregorian ( jy , jm , jd ) ;
return ` ${ gy } - ${ String ( gm ) . padStart ( 2 , '0' ) } - ${ String ( gd ) . padStart ( 2 , '0' ) } ` ;
}
function formatPersianDate ( gDate : string ) : string {
const j = gToJ ( gDate ) ;
if ( ! j ) return gDate ;
return ` ${ toPersianNums ( j . jd ) } ${ JALALI_MONTHS [ j . jm - 1 ] } ${ toPersianNums ( j . jy ) } ` ;
}
function jFirstDayOfWeek ( jy : number , jm : number ) : number {
const { gy , gm , gd } = jalaali . toGregorian ( jy , jm , 1 ) ;
const dow = new Date ( gy , gm - 1 , gd ) . getDay ( ) ; // 0=Sunday
return ( dow + 1 ) % 7 ; // 0=Saturday, 1=Sunday, ..., 6=Friday
}
function todayGregorian ( ) : string { return new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ; }
// ── Persian Date Picker ────────────────────────────────────────────────────
function PersianDateInput ( { value , onChange , minDate , placeholder } : {
value : string ; onChange : ( v : string ) = > void ; minDate? : string ; placeholder? : string ;
} ) {
const [ open , setOpen ] = useState ( false ) ;
const [ viewJy , setViewJy ] = useState ( 0 ) ;
const [ viewJm , setViewJm ] = useState ( 0 ) ;
const [ rect , setRect ] = useState < DOMRect | null > ( null ) ;
const btnRef = useRef < HTMLButtonElement > ( null ) ;
const dropRef = useRef < HTMLDivElement > ( null ) ;
useEffect ( ( ) = > {
if ( ! open ) return ;
const j = gToJ ( value || todayGregorian ( ) ) ;
if ( j ) { setViewJy ( j . jy ) ; setViewJm ( j . jm ) ; }
} , [ open , value ] ) ;
useEffect ( ( ) = > {
if ( ! open ) return ;
const onDown = ( e : MouseEvent ) = > {
if ( dropRef . current && ! dropRef . current . contains ( e . target as Node ) &&
btnRef . current && ! btnRef . current . contains ( e . target as Node ) ) setOpen ( false ) ;
} ;
document . addEventListener ( 'mousedown' , onDown ) ;
return ( ) = > document . removeEventListener ( 'mousedown' , onDown ) ;
} , [ open ] ) ;
const openPicker = ( ) = > {
if ( ! btnRef . current ) return ;
setRect ( btnRef . current . getBoundingClientRect ( ) ) ;
setOpen ( v = > ! v ) ;
} ;
const prevMonth = ( ) = > viewJm === 1 ? ( setViewJy ( y = > y - 1 ) , setViewJm ( 12 ) ) : setViewJm ( m = > m - 1 ) ;
const nextMonth = ( ) = > viewJm === 12 ? ( setViewJy ( y = > y + 1 ) , setViewJm ( 1 ) ) : setViewJm ( m = > m + 1 ) ;
const selectDay = ( jd : number ) = > { onChange ( jToG ( viewJy , viewJm , jd ) ) ; setOpen ( false ) ; } ;
const selectedJ = gToJ ( value ) ;
const minJ = minDate ? gToJ ( minDate ) : null ;
const todayJ = gToJ ( todayGregorian ( ) ) ;
const daysInMonth = viewJy && viewJm ? jalaali . jalaaliMonthLength ( viewJy , viewJm ) : 0 ;
const firstDow = viewJy && viewJm ? jFirstDayOfWeek ( viewJy , viewJm ) : 0 ;
const isSelected = ( jd : number ) = > ! ! selectedJ && selectedJ . jy === viewJy && selectedJ . jm === viewJm && selectedJ . jd === jd ;
const isToday = ( jd : number ) = > ! ! todayJ && todayJ . jy === viewJy && todayJ . jm === viewJm && todayJ . jd === jd ;
const isDisabled = ( jd : number ) = > {
if ( ! minJ ) return false ;
if ( viewJy < minJ . jy ) return true ;
if ( viewJy === minJ . jy && viewJm < minJ . jm ) return true ;
if ( viewJy === minJ . jy && viewJm === minJ . jm && jd < minJ . jd ) return true ;
return false ;
} ;
const dropStyle : React.CSSProperties = rect
? { position : 'fixed' , top : rect.bottom + 4 , left : rect.left , zIndex : 9999 , width : 288 }
: { } ;
return (
< div >
< button ref = { btnRef } type = "button" onClick = { openPicker }
className = "cp-input h-11 flex items-center justify-between w-full cursor-pointer" >
< span className = { value ? 'text-slate-800 dark:text-slate-200' : 'text-slate-400 dark:text-slate-500' } >
{ value ? formatPersianDate ( value ) : ( placeholder ? ? 'انتخاب تاریخ' ) }
< / span >
< CalendarIcon className = "w-4 h-4 text-slate-400 shrink-0 mr-2" / >
< / button >
{ open && createPortal (
< div ref = { dropRef } style = { dropStyle }
className = "bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-2xl shadow-2xl overflow-hidden" >
< div className = "flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-gray-800" >
< button type = "button" onClick = { nextMonth }
className = "w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 text-slate-500 transition-colors" >
< ChevronRightIcon className = "w-4 h-4" / >
< / button >
< span className = "text-sm font-semibold text-slate-800 dark:text-slate-100" dir = "rtl" >
{ viewJm > 0 ? JALALI_MONTHS [ viewJm - 1 ] : '' } { toPersianNums ( viewJy ) }
< / span >
< button type = "button" onClick = { prevMonth }
className = "w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 text-slate-500 transition-colors" >
< ChevronLeftIcon className = "w-4 h-4" / >
< / button >
< / div >
< div className = "grid grid-cols-7 px-3 pt-2 pb-1" >
{ JALALI_DAYS_SHORT . map ( d = > (
< div key = { d } className = "text-center text-xs font-medium text-slate-400 dark:text-slate-500 py-1" > { d } < / div >
) ) }
< / div >
< div className = "grid grid-cols-7 gap-0.5 px-3 pb-3" >
{ Array . from ( { length : firstDow } ) . map ( ( _ , i ) = > < div key = { ` e- ${ i } ` } / > ) }
{ Array . from ( { length : daysInMonth } ) . map ( ( _ , i ) = > {
const jd = i + 1 ;
const sel = isSelected ( jd ) ;
const tod = isToday ( jd ) ;
const dis = isDisabled ( jd ) ;
return (
< button key = { jd } type = "button" disabled = { dis } onClick = { ( ) = > selectDay ( jd ) }
className = { ` w-full aspect-square flex items-center justify-center rounded-lg text-sm transition-colors
${ dis ? 'text-slate-300 dark:text-gray-600 cursor-not-allowed'
: sel ? 'bg-primary-500 text-white font-semibold shadow-sm'
: tod ? 'ring-1 ring-primary-400 text-primary-700 dark:text-primary-300 font-medium'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-800' } ` } >
{ toPersianNums ( jd ) }
< / button >
) ;
} ) }
< / div >
< / div > ,
document . body
) }
< / div >
) ;
}
const SCHEDULE_DAYS = [
{ key : 'saturday' , label : 'شنبه' } ,
{ key : 'sunday' , label : 'یکشنبه' } ,
{ key : 'monday' , label : 'دوشنبه' } ,
{ key : 'tuesday' , label : 'سهشنبه' } ,
{ key : 'wednesday' , label : 'چهارشنبه' } ,
{ key : 'thursday' , label : 'پنجشنبه' } ,
{ key : 'friday' , label : 'جمعه' } ,
] ;
const DURATION_OPTS = [ 15 , 20 , 30 , 45 , 60 ] ;
const EMPTY_SCHEDULE : ScheduleMap = Object . fromEntries (
SCHEDULE_DAYS . map ( d = > [ d . key , { active : false , slots : [ ] } ] )
) ;
function tsToDate ( ts : number ) : string {
return new Date ( ts * 1000 ) . toISOString ( ) . slice ( 0 , 10 ) ;
}
// ── Image upload ───────────────────────────────────────────────────────────
async function uploadDoctorImage ( file : File ) : Promise < ImageFileData > {
@@ -736,6 +914,501 @@ function AddressCard({ addr, onEdit, onDelete }: {
) ;
}
// ── Schedule components ────────────────────────────────────────────────────
function SlotEditor ( { slots , onChange } : {
slots : SlotConfig [ ] ;
onChange : ( slots : SlotConfig [ ] ) = > void ;
} ) {
const add = ( ) = > onChange ( [ . . . slots , { start : '09:00' , end : '13:00' , duration : 30 } ] ) ;
const remove = ( i : number ) = > onChange ( slots . filter ( ( _ , idx ) = > idx !== i ) ) ;
const update = ( i : number , field : keyof SlotConfig , val : string | number ) = >
onChange ( slots . map ( ( s , idx ) = > idx === i ? { . . . s , [ field ] : val } : s ) ) ;
return (
< div className = "space-y-2" >
{ slots . map ( ( slot , i ) = > (
< div key = { i } className = "flex items-center gap-2 flex-wrap" >
< span className = "text-xs text-slate-500 dark:text-slate-400 shrink-0" > ا ز < / span >
< input type = "time" value = { slot . start } onChange = { e = > update ( i , 'start' , e . target . value ) }
className = "cp-input h-8 w-28 text-sm font-mono text-center px-2" / >
< 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 ) }
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 ) ) }
className = "cp-select h-8 text-sm w-24" >
{ DURATION_OPTS . map ( d = > < option key = { d } value = { d } > { d } د ق ی ق ه < / option > ) }
< / select >
< 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" >
< XMarkIcon className = "w-3.5 h-3.5" / >
< / button >
< / div >
) ) }
< button type = "button" onClick = { add }
className = "flex items-center gap-1.5 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-1" >
< PlusIcon className = "w-3.5 h-3.5" / > ا ف ز و د ن ب ا ز ه ز م ا ن ی
< / button >
< / div >
) ;
}
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
function WeeklyScheduleTab ( { doctorUuid } : { doctorUuid : string } ) {
const qc = useQueryClient ( ) ;
const [ scheduleMap , setScheduleMap ] = useState < ScheduleMap > ( EMPTY_SCHEDULE ) ;
const [ scheduleUuid , setScheduleUuid ] = useState < string | null > ( null ) ;
const scheduleQ = useQuery ( {
queryKey : [ 'doctor-schedule' , doctorUuid ] ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( ` /api/v1/appointment-settings/weekly-schedule/ ${ doctorUuid } ` ) ,
staleTime : 0 ,
retry : ( count , err ) = > ! ( err instanceof ApiError && err . status === 404 ) ,
} ) ;
useEffect ( ( ) = > {
if ( scheduleQ . data ) {
const d : WeeklyScheduleData = scheduleQ . data ? . data ? . data ? ? scheduleQ . data ? . data ;
if ( d ? . schedule ) { setScheduleMap ( { . . . EMPTY_SCHEDULE , . . . d . schedule } ) ; setScheduleUuid ( d . uuid ) ; }
} else if ( scheduleQ . error instanceof ApiError && scheduleQ . error . status === 404 ) {
setScheduleMap ( EMPTY_SCHEDULE ) ; setScheduleUuid ( null ) ;
}
} , [ scheduleQ . data , scheduleQ . error ] ) ;
const saveMut = useMutation ( {
mutationFn : ( ) = > scheduleUuid
? api . patch < ApiResponse < any > > ( ` /api/v1/appointment-settings/weekly-schedule/ ${ doctorUuid } ` , { schedule : scheduleMap } )
: api . post < ApiResponse < any > > ( '/api/v1/appointment-settings/weekly-schedule' , { doctor_uuid : doctorUuid , schedule : scheduleMap } ) ,
onSuccess : ( res ) = > {
const d : WeeklyScheduleData = res ? . data ? . data ? ? res ? . data ;
if ( d ? . uuid && ! scheduleUuid ) setScheduleUuid ( d . uuid ) ;
toast . success ( 'برنامه هفتگی ذخیره شد' ) ;
qc . invalidateQueries ( { queryKey : [ 'doctor-schedule' , doctorUuid ] } ) ;
} ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
const setDay = ( key : string , patch : Partial < DayConfig > ) = >
setScheduleMap ( prev = > ( { . . . prev , [ key ] : { . . . prev [ key ] , . . . patch } } ) ) ;
if ( scheduleQ . isLoading ) return (
< div className = "space-y-3" > { Array . from ( { length : 4 } ) . map ( ( _ , i ) = > < div key = { i } className = "h-12 rounded-xl skeleton" / > ) } < / div >
) ;
return (
< div className = "space-y-2" >
{ SCHEDULE_DAYS . map ( day = > {
const cfg = scheduleMap [ day . key ] ? ? { active : false , slots : [ ] } ;
const totalSlots = cfg . slots . reduce ( ( acc , s ) = > {
const [ sh , sm ] = s . start . split ( ':' ) . map ( Number ) ;
const [ eh , em ] = s . end . split ( ':' ) . map ( Number ) ;
const mins = ( eh * 60 + em ) - ( sh * 60 + sm ) ;
return acc + Math . floor ( mins / Math . max ( s . duration , 1 ) ) ;
} , 0 ) ;
return (
< div key = { day . key } className = { ` rounded-xl border transition-colors ${ cfg . active ? 'border-primary-200 dark:border-primary-500/30 bg-primary-50/30 dark:bg-primary-500/5' : 'border-slate-200 dark:border-gray-700 bg-slate-50/60 dark:bg-gray-800/40' } ` } >
< div className = "flex items-center gap-3 px-4 py-3" >
< button type = "button"
onClick = { ( ) = > setDay ( day . key , { active : ! cfg . active } ) }
className = { ` relative w-10 h-5 rounded-full transition-colors shrink-0 focus:outline-none ${ cfg . active ? 'bg-primary-500' : 'bg-slate-300 dark:bg-gray-600' } ` } >
< span className = { ` absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-150 ${ cfg . active ? 'translate-x-5' : 'translate-x-0.5' } ` } / >
< / button >
< span className = { ` text-sm font-medium w-20 shrink-0 ${ cfg . active ? 'text-primary-700 dark:text-primary-300' : 'text-slate-500 dark:text-slate-400' } ` } > { day . label } < / span >
{ ! cfg . active && < span className = "text-xs text-slate-400 dark:text-slate-500" > ت ع ط ی ل < / span > }
{ cfg . active && cfg . slots . length > 0 && (
< span className = "text-xs text-slate-500 dark:text-slate-400" >
{ cfg . slots . length } ب ا ز ه · { totalSlots } ن و ب ت
< / span >
) }
{ cfg . active && cfg . slots . length === 0 && (
< span className = "text-xs text-amber-500 dark:text-amber-400" > ه ن و ز ب ا ز ه ا ی ت ن ظ ی م ن ش د ه < / span >
) }
< / div >
{ cfg . active && (
< div className = "border-t border-primary-100 dark:border-primary-500/20 px-4 pb-4 pt-3" >
< SlotEditor slots = { cfg . slots } onChange = { slots = > setDay ( day . key , { slots } ) } / >
< / div >
) }
< / div >
) ;
} ) }
< div className = "flex justify-end pt-2" >
< button type = "button" onClick = { ( ) = > saveMut . mutate ( ) } disabled = { saveMut . isPending }
className = "cp-btn-primary" >
{ saveMut . isPending
? < > < span className = "w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" / > د ر ح ا ل ذ خ ی ر ه . . . < / >
: 'ذخیره برنامه هفتگی'
}
< / button >
< / div >
< / div >
) ;
}
// ── Date Override Modal ────────────────────────────────────────────────────
function DateOverrideModal ( { open , onClose , existing , doctorUuid , onSaved } : {
open : boolean ; onClose : ( ) = > void ;
existing : DateOverrideData | null ; doctorUuid : string ;
onSaved : ( ) = > void ;
} ) {
const [ dateStr , setDateStr ] = useState ( '' ) ;
const [ active , setActive ] = useState ( false ) ;
const [ reason , setReason ] = useState ( '' ) ;
const [ slots , setSlots ] = useState < SlotConfig [ ] > ( [ ] ) ;
useEffect ( ( ) = > {
if ( open ) {
if ( existing ) {
setDateStr ( tsToDate ( existing . date ) ) ;
setActive ( existing . active ) ;
setReason ( existing . reason ? ? '' ) ;
setSlots ( existing . custom_slots ? ? [ ] ) ;
} else {
setDateStr ( new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ) ;
setActive ( false ) ; setReason ( '' ) ; setSlots ( [ ] ) ;
}
}
} , [ open , existing ] ) ;
const saveMut = useMutation ( {
mutationFn : ( ) = > {
const body = { date : dateStr , active , reason : reason || undefined , custom_slots : active ? slots : [ ] } ;
if ( existing )
return api . patch < ApiResponse < any > > ( ` /api/v1/appointment-settings/date-override/ ${ existing . uuid } ` , body ) ;
return api . post < ApiResponse < any > > ( '/api/v1/appointment-settings/date-override' , { . . . body , doctor_uuid : doctorUuid } ) ;
} ,
onSuccess : ( ) = > { toast . success ( existing ? 'ویرایش شد' : 'تاریخ خاص اضافه شد' ) ; onSaved ( ) ; onClose ( ) ; } ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
return (
< Modal open = { open } title = { existing ? 'ویرایش تاریخ خاص' : 'افزودن تاریخ خاص' } size = "md" onClose = { onClose }
footer = {
< >
< button type = "button" onClick = { onClose } className = "cp-btn-secondary px-5" > ل غ و < / button >
< button type = "button" onClick = { ( ) = > saveMut . mutate ( ) } disabled = { saveMut . isPending || ! dateStr }
className = "cp-btn-primary" >
{ saveMut . isPending ? 'در حال ذخیره...' : 'ذخیره' }
< / button >
< / >
}
>
< div className = "space-y-4" >
< div >
< label className = "block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5" > ت ا ر ی خ < / label >
< PersianDateInput value = { dateStr } onChange = { setDateStr } placeholder = "انتخاب تاریخ" / >
< / div >
< div className = "flex items-center gap-3" >
< button type = "button" onClick = { ( ) = > setActive ( v = > ! v ) }
className = { ` relative w-10 h-5 rounded-full transition-colors shrink-0 focus:outline-none ${ active ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-gray-600' } ` } >
< span className = { ` absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-150 ${ active ? 'translate-x-5' : 'translate-x-0.5' } ` } / >
< / button >
< span className = "text-sm text-slate-700 dark:text-slate-300" >
{ active ? 'این روز فعال است (نوبتدهی میشود)' : 'این روز تعطیل است (بدون نوبت)' }
< / span >
< / div >
< div >
< label className = "block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5" > د ل ی ل ( ا خ ت ی ا ر ی ) < / label >
< input type = "text" value = { reason } onChange = { e = > setReason ( e . target . value ) }
placeholder = "مثال: شیفت اضطراری، جلسه..." className = "cp-input" / >
< / div >
{ active ? (
< div >
< label className = "block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5" > ب ا ز ه ه ا ی ز م ا ن ی س ف ا ر ش ی < / label >
< p className = "text-xs text-slate-400 dark:text-slate-500 mb-2" > ا گ ر خ ا ل ی ب م ا ن د ا ز ب ر ن ا م ه ه ف ت گ ی ا س ت ف ا د ه م ی ش و د < / p >
< SlotEditor slots = { slots } onChange = { setSlots } / >
< / div >
) : (
< div className = "p-3 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30" >
< p className = "text-xs text-amber-700 dark:text-amber-400" >
د ر ا ی ن ر و ز ه ی چ ن و ب ت ی د ا د ه ن خ و ا ه د ش د ، ح ت ی ا گ ر د ر ب ر ن ا م ه ه ف ت گ ی ف ع ا ل ب ا ش د .
< / p >
< / div >
) }
< / div >
< / Modal >
) ;
}
// ── Date Overrides Tab ─────────────────────────────────────────────────────
function DateOverridesTab ( { doctorUuid } : { doctorUuid : string } ) {
const qc = useQueryClient ( ) ;
const [ modalOpen , setModalOpen ] = useState ( false ) ;
const [ editing , setEditing ] = useState < DateOverrideData | null > ( null ) ;
const [ deletingUuid , setDeletingUuid ] = useState < string | null > ( null ) ;
const listQ = useQuery ( {
queryKey : [ 'doctor-overrides' , doctorUuid ] ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( ` /api/v1/appointment-settings/date-override/list/ ${ doctorUuid } ` ) ,
staleTime : 0 ,
} ) ;
const overrides : DateOverrideData [ ] = useMemo (
( ) = > listQ . data ? . data ? . data ? ? listQ . data ? . data ? ? [ ] , [ listQ . data ]
) ;
const deleteMut = useMutation ( {
mutationFn : ( uuid : string ) = > api . delete < ApiResponse < any > > ( ` /api/v1/appointment-settings/date-override/ ${ uuid } ` ) ,
onSuccess : ( ) = > { toast . success ( 'حذف شد' ) ; setDeletingUuid ( null ) ; qc . invalidateQueries ( { queryKey : [ 'doctor-overrides' , doctorUuid ] } ) ; } ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
if ( listQ . isLoading ) return (
< div className = "space-y-2" > { Array . from ( { length : 3 } ) . map ( ( _ , i ) = > < div key = { i } className = "h-14 rounded-xl skeleton" / > ) } < / div >
) ;
return (
< div className = "space-y-3" >
< div className = "flex justify-end" >
< button type = "button" onClick = { ( ) = > { setEditing ( null ) ; setModalOpen ( true ) ; } }
className = "flex items-center gap-1.5 text-sm cp-btn-primary" >
< PlusIcon className = "w-4 h-4" / > ت ا ر ی خ خ ا ص ج د ی د
< / button >
< / div >
{ overrides . length === 0 ? (
< div className = "text-center py-10 text-slate-400 dark:text-slate-500" >
< CalendarIcon className = "w-10 h-10 mx-auto mb-2 opacity-30" / >
< p className = "text-sm" > ه ی چ ت ا ر ی خ خ ا ص ی ت ن ظ ی م ن ش د ه < / p >
< / div >
) : (
< div className = "space-y-2" >
{ overrides . map ( ov = > (
< div key = { ov . uuid } className = "flex items-center gap-3 p-3.5 rounded-xl border border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/40 group" >
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2 flex-wrap" >
< span className = "text-sm font-medium text-slate-800 dark:text-slate-200" > { formatPersianDate ( tsToDate ( ov . date ) ) } < / span >
< span className = { ` text-xs px-2 py-0.5 rounded-full font-medium ${ ov . active ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300' : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400' } ` } >
{ ov . active ? 'فعال' : 'تعطیل' }
< / span >
{ ov . custom_slots . length > 0 && (
< span className = "text-xs text-slate-400 dark:text-slate-500" > { ov . custom_slots . length } ب ا ز ه س ف ا ر ش ی < / span >
) }
< / div >
{ ov . reason && < p className = "text-xs text-slate-400 dark:text-slate-500 mt-0.5" > { ov . reason } < / p > }
< / div >
< div className = "opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0" >
< button onClick = { ( ) = > { setEditing ( ov ) ; setModalOpen ( true ) ; } }
className = "w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors" >
< PencilIcon className = "w-3.5 h-3.5" / >
< / button >
< button onClick = { ( ) = > setDeletingUuid ( ov . uuid ) }
className = "w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors" >
< TrashIcon className = "w-3.5 h-3.5" / >
< / button >
< / div >
< / div >
) ) }
< / div >
) }
< DateOverrideModal open = { modalOpen } onClose = { ( ) = > { setModalOpen ( false ) ; setEditing ( null ) ; } }
existing = { editing } doctorUuid = { doctorUuid }
onSaved = { ( ) = > qc . invalidateQueries ( { queryKey : [ 'doctor-overrides' , doctorUuid ] } ) } / >
< ConfirmDialog open = { ! ! deletingUuid } title = "حذف تاریخ خاص" message = "آیا از حذف این تاریخ خاص اطمینان دارید؟"
confirmLabel = "بله، حذف کن" danger loading = { deleteMut . isPending }
onConfirm = { ( ) = > deletingUuid && deleteMut . mutate ( deletingUuid ) } onCancel = { ( ) = > setDeletingUuid ( null ) } / >
< / div >
) ;
}
// ── Holiday Modal ──────────────────────────────────────────────────────────
function HolidayModal ( { open , onClose , existing , doctorUuid , onSaved } : {
open : boolean ; onClose : ( ) = > void ;
existing : HolidayData | null ; doctorUuid : string ;
onSaved : ( ) = > void ;
} ) {
const [ startDate , setStartDate ] = useState ( '' ) ;
const [ endDate , setEndDate ] = useState ( '' ) ;
const [ reason , setReason ] = useState ( '' ) ;
useEffect ( ( ) = > {
if ( open ) {
if ( existing ) {
setStartDate ( tsToDate ( existing . start_date ) ) ;
setEndDate ( tsToDate ( existing . end_date ) ) ;
setReason ( existing . reason ? ? '' ) ;
} else {
const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
setStartDate ( today ) ; setEndDate ( today ) ; setReason ( '' ) ;
}
}
} , [ open , existing ] ) ;
const invalid = ! startDate || ! endDate || endDate < startDate ;
const saveMut = useMutation ( {
mutationFn : ( ) = > {
const body = { start_date : startDate , end_date : endDate , reason : reason || undefined } ;
if ( existing )
return api . patch < ApiResponse < any > > ( ` /api/v1/appointment-settings/holidays/ ${ existing . uuid } ` , body ) ;
return api . post < ApiResponse < any > > ( '/api/v1/appointment-settings/holidays' , { . . . body , doctor_uuid : doctorUuid } ) ;
} ,
onSuccess : ( ) = > { toast . success ( existing ? 'تعطیلات ویرایش شد' : 'تعطیلات اضافه شد' ) ; onSaved ( ) ; onClose ( ) ; } ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
return (
< Modal open = { open } title = { existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات' } size = "sm" onClose = { onClose }
footer = {
< >
< button type = "button" onClick = { onClose } className = "cp-btn-secondary px-5" > ل غ و < / button >
< button type = "button" onClick = { ( ) = > saveMut . mutate ( ) } disabled = { saveMut . isPending || invalid }
className = "cp-btn-primary" >
{ saveMut . isPending ? 'در حال ذخیره...' : 'ذخیره' }
< / button >
< / >
}
>
< div className = "space-y-4" >
< div className = "grid grid-cols-2 gap-3" >
< div >
< label className = "block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5" > ا ز ت ا ر ی خ < / label >
< PersianDateInput value = { startDate } onChange = { v = > { setStartDate ( v ) ; if ( endDate && v > endDate ) setEndDate ( v ) ; } } / >
< / div >
< div >
< label className = "block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5" > ت ا ت ا ر ی خ < / label >
< PersianDateInput value = { endDate } onChange = { setEndDate } minDate = { startDate } / >
< / div >
< / div >
{ endDate && startDate && endDate < startDate && (
< p className = "text-xs text-red-500" > ت ا ر ی خ پ ا ی ا ن ن م ی ت و ا ن د ق ب ل ا ز ت ا ر ی خ ش ر و ع ب ا ش د < / p >
) }
< div >
< label className = "block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5" > د ل ی ل ( ا خ ت ی ا ر ی ) < / label >
< input type = "text" value = { reason } onChange = { e = > setReason ( e . target . value ) }
placeholder = "مثال: سفر، کنگره پزشکی..." className = "cp-input" / >
< / div >
< / div >
< / Modal >
) ;
}
// ── Holidays Tab ───────────────────────────────────────────────────────────
function HolidaysTab ( { doctorUuid } : { doctorUuid : string } ) {
const qc = useQueryClient ( ) ;
const [ modalOpen , setModalOpen ] = useState ( false ) ;
const [ editing , setEditing ] = useState < HolidayData | null > ( null ) ;
const [ deletingUuid , setDeletingUuid ] = useState < string | null > ( null ) ;
const listQ = useQuery ( {
queryKey : [ 'doctor-holidays' , doctorUuid ] ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( ` /api/v1/appointment-settings/holidays/list/ ${ doctorUuid } ` ) ,
staleTime : 0 ,
} ) ;
const holidays : HolidayData [ ] = useMemo (
( ) = > listQ . data ? . data ? . data ? ? listQ . data ? . data ? ? [ ] , [ listQ . data ]
) ;
const deleteMut = useMutation ( {
mutationFn : ( uuid : string ) = > api . delete < ApiResponse < any > > ( ` /api/v1/appointment-settings/holidays/ ${ uuid } ` ) ,
onSuccess : ( ) = > { toast . success ( 'حذف شد' ) ; setDeletingUuid ( null ) ; qc . invalidateQueries ( { queryKey : [ 'doctor-holidays' , doctorUuid ] } ) ; } ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
const toggleMut = useMutation ( {
mutationFn : ( h : HolidayData ) = > api . patch < ApiResponse < any > > ( ` /api/v1/appointment-settings/holidays/ ${ h . uuid } ` , { active : ! h . active } ) ,
onSuccess : ( ) = > { toast . success ( 'وضعیت بروز شد' ) ; qc . invalidateQueries ( { queryKey : [ 'doctor-holidays' , doctorUuid ] } ) ; } ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
if ( listQ . isLoading ) return (
< div className = "space-y-2" > { Array . from ( { length : 3 } ) . map ( ( _ , i ) = > < div key = { i } className = "h-14 rounded-xl skeleton" / > ) } < / div >
) ;
return (
< div className = "space-y-3" >
< div className = "flex justify-end" >
< button type = "button" onClick = { ( ) = > { setEditing ( null ) ; setModalOpen ( true ) ; } }
className = "flex items-center gap-1.5 text-sm cp-btn-primary" >
< PlusIcon className = "w-4 h-4" / > ت ع ط ی ل ا ت ج د ی د
< / button >
< / div >
{ holidays . length === 0 ? (
< div className = "text-center py-10 text-slate-400 dark:text-slate-500" >
< CalendarIcon className = "w-10 h-10 mx-auto mb-2 opacity-30" / >
< p className = "text-sm" > ه ی چ ت ع ط ی ل ا ت ی ث ب ت ن ش د ه < / p >
< / div >
) : (
< div className = "space-y-2" >
{ holidays . map ( h = > {
const sameDay = tsToDate ( h . start_date ) === tsToDate ( h . end_date ) ;
return (
< div key = { h . uuid } className = "flex items-center gap-3 p-3.5 rounded-xl border border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/40 group" >
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2 flex-wrap" >
< span className = "text-sm font-medium text-slate-800 dark:text-slate-200" >
{ sameDay ? formatPersianDate ( tsToDate ( h . start_date ) ) : ` ${ formatPersianDate ( tsToDate ( h . start_date ) ) } تا ${ formatPersianDate ( tsToDate ( h . end_date ) ) } ` }
< / span >
< span className = { ` text-xs px-2 py-0.5 rounded-full font-medium ${ h . active ? 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400' : 'bg-slate-100 dark:bg-gray-700 text-slate-500 dark:text-slate-400' } ` } >
{ h . active ? 'فعال' : 'غیرفعال' }
< / span >
< / div >
{ h . reason && < p className = "text-xs text-slate-400 dark:text-slate-500 mt-0.5" > { h . reason } < / p > }
< / div >
< div className = "opacity-0 group-hover:opacity-100 flex items-center gap-1.5 transition-opacity shrink-0" >
< button onClick = { ( ) = > toggleMut . mutate ( h ) } disabled = { toggleMut . isPending }
className = "text-xs px-2.5 h-7 rounded-lg border border-slate-200 dark:border-gray-600 text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors disabled:opacity-50" >
{ h . active ? 'غیرفعال کن' : 'فعال کن' }
< / button >
< button onClick = { ( ) = > { setEditing ( h ) ; setModalOpen ( true ) ; } }
className = "w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors" >
< PencilIcon className = "w-3.5 h-3.5" / >
< / button >
< button onClick = { ( ) = > setDeletingUuid ( h . uuid ) }
className = "w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors" >
< TrashIcon className = "w-3.5 h-3.5" / >
< / button >
< / div >
< / div >
) ;
} ) }
< / div >
) }
< HolidayModal open = { modalOpen } onClose = { ( ) = > { setModalOpen ( false ) ; setEditing ( null ) ; } }
existing = { editing } doctorUuid = { doctorUuid }
onSaved = { ( ) = > qc . invalidateQueries ( { queryKey : [ 'doctor-holidays' , doctorUuid ] } ) } / >
< ConfirmDialog open = { ! ! deletingUuid } title = "حذف تعطیلات" message = "آیا از حذف این تعطیلات اطمینان دارید؟"
confirmLabel = "بله، حذف کن" danger loading = { deleteMut . isPending }
onConfirm = { ( ) = > deletingUuid && deleteMut . mutate ( deletingUuid ) } onCancel = { ( ) = > setDeletingUuid ( null ) } / >
< / div >
) ;
}
// ── Schedule Section ───────────────────────────────────────────────────────
const SCHEDULE_TABS = [
{ id : 'weekly' as const , label : 'ساعات کاری هفتگی' } ,
{ id : 'overrides' as const , label : 'تاریخهای خاص' } ,
{ id : 'holidays' as const , label : 'تعطیلات' } ,
] ;
function ScheduleSection ( { doctorUuid } : { doctorUuid : string } ) {
const [ tab , setTab ] = useState < 'weekly' | 'overrides' | 'holidays' > ( 'weekly' ) ;
return (
< div className = "cp-card p-6" >
< h2 className = "text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4" > ب ر ن ا م ه ک ا ر ی < / h2 >
< div className = "flex gap-1 mb-5 bg-slate-100 dark:bg-gray-800 p-1 rounded-xl" >
{ SCHEDULE_TABS . map ( t = > (
< button key = { t . id } type = "button" onClick = { ( ) = > setTab ( t . id ) }
className = { ` flex-1 text-xs sm:text-sm font-medium px-2 py-1.5 rounded-lg transition-colors ${
tab === t . id
? 'bg-white dark:bg-gray-700 text-slate-800 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
} ` } >
{ t . label }
< / button >
) ) }
< / div >
{ tab === 'weekly' && < WeeklyScheduleTab doctorUuid = { doctorUuid } / > }
{ tab === 'overrides' && < DateOverridesTab doctorUuid = { doctorUuid } / > }
{ tab === 'holidays' && < HolidaysTab doctorUuid = { doctorUuid } / > }
< / div >
) ;
}
// ── Edit schema ────────────────────────────────────────────────────────────
const editSchema = z . object ( {
@@ -758,13 +1431,14 @@ export default function DoctorDetailPage() {
const [ searchParams ] = useSearchParams ( ) ;
const qc = useQueryClient ( ) ;
const [ editOpen , setEditOpen ] = useState ( searchParams . get ( 'edit' ) === '1' ) ;
const [ deleteOpen , setDeleteOpen ] = useState ( false ) ;
const [ menuOpen , setMenuOpen ] = useState ( false ) ;
const [ uploadingImg , setUploadingImg ] = useState ( false ) ;
const [ addrModalOpen , setAddrModalOpen ] = useState ( false ) ;
const [ editingAddr , setEditingAddr ] = useState < AddressData | null > ( null ) ;
const [ deletingAddrId , setDeletingAddrId ] = useState < string | null > ( null ) ;
const [ editOpen , setEditOpen ] = useState ( searchParams . get ( 'edit' ) === '1' ) ;
const [ deleteOpen , setDeleteOpen ] = useState ( false ) ;
const [ toggleConfirm , setToggleConfirm ] = useState ( false ) ;
const [ menuOpen , setMenuOpen ] = useState ( false ) ;
const [ uploadingImg , setUploadingImg ] = useState ( false ) ;
const [ addrModalOpen , setAddrModalOpen ] = useState ( false ) ;
const [ editingAddr , setEditingAddr ] = useState < AddressData | null > ( null ) ;
const [ deletingAddrId , setDeletingAddrId ] = useState < string | null > ( null ) ;
// ── Queries ──
@@ -995,26 +1669,37 @@ export default function DoctorDetailPage() {
) }
< / div >
< div className = "flex items-center gap-2 sm:mb-1" >
< div className = "flex items-center gap-2 sm:mb-1 flex-wrap " >
< button onClick = { ( ) = > setEditOpen ( true ) } className = "cp-btn-primary text-sm" >
< PencilIcon className = "w-4 h-4" / > و ی ر ا ی ش
< / button >
{ /* Toggle active button — styled by current state */ }
< button
onClick = { ( ) = > setToggleConfirm ( true ) }
disabled = { toggleMut . isPending }
className = { ` inline-flex items-center gap-1.5 text-sm font-medium px-3 h-10 rounded-xl border transition-colors disabled:opacity-50 ${
doctor . active
? 'border-orange-300 dark:border-orange-500/40 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-500/10 hover:bg-orange-100 dark:hover:bg-orange-500/20'
: 'border-emerald-300 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20'
} ` }
>
{ toggleMut . isPending
? < span className = "w-3.5 h-3.5 border-2 border-current/30 border-t-current rounded-full animate-spin" / >
: doctor . active
? < XCircleIcon className = "w-4 h-4" / >
: < CheckCircleIcon className = "w-4 h-4" / >
}
{ doctor . active ? 'غیرفعال کردن' : 'فعال کردن' }
< / button >
< div className = "relative" data - dmenu >
< button data - dmenu onClick = { ( ) = > setMenuOpen ( ! menuOpen ) }
className = "cp-btn-secondary h-10 w-10 flex items-center justify-center px-0" >
< EllipsisVerticalIcon className = "w-4 h-4" / >
< / button >
{ menuOpen && (
< div className = "absolute left-0 top-12 z-30 w-56 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data - dmenu >
< button onClick = { ( ) = > { toggleMut . mutate ( ) ; setMenuOpen ( false ) ; } }
disabled = { toggleMut . isPending }
className = "w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 disabled:opacity-50" >
{ doctor . active
? < > < XCircleIcon className = "w-4 h-4 text-orange-400 shrink-0" / > غ ی ر ف ع ا ل ک ر د ن پ ز ش ک < / >
: < > < CheckCircleIcon className = "w-4 h-4 text-emerald-500 shrink-0" / > ف ع ا ل ک ر د ن پ ز ش ک < / >
}
< / button >
< div className = "border-t border-slate-100 dark:border-gray-700 my-1" / >
< div className = "absolute left-0 top-12 z-30 w-52 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data - dmenu >
< button onClick = { ( ) = > { setDeleteOpen ( true ) ; setMenuOpen ( false ) ; } }
className = "w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10" >
< TrashIcon className = "w-4 h-4 shrink-0" / > ح ذ ف ا ی ن پ ز ش ک
@@ -1080,6 +1765,8 @@ export default function DoctorDetailPage() {
) }
< / div >
{ uuid && < ScheduleSection doctorUuid = { uuid } / > }
{ doctor . clinics && doctor . clinics . length > 0 && (
< div className = "cp-card p-6" >
< h2 className = "text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3" >
@@ -1270,6 +1957,20 @@ export default function DoctorDetailPage() {
onConfirm = { ( ) = > deletingAddrId && deleteAddrMut . mutate ( deletingAddrId ) }
onCancel = { ( ) = > setDeletingAddrId ( null ) }
/ >
< ConfirmDialog
open = { toggleConfirm }
title = { doctor . active ? 'غیرفعال کردن پزشک' : 'فعال کردن پزشک' }
message = {
doctor . active
? ` آیا مطمئن هستید که میخواهید دکتر " ${ doctor . name } " را غیرفعال کنید؟ پزشک دیگر قادر به دریافت نوبت نخواهد بود. `
: ` آیا میخواهید دکتر " ${ doctor . name } " را فعال کنید؟ پزشک مجدداً قادر به دریافت نوبت خواهد بود. `
}
confirmLabel = { doctor . active ? 'بله، غیرفعال کن' : 'بله، فعال کن' }
danger = { doctor . active }
loading = { toggleMut . isPending }
onConfirm = { ( ) = > { toggleMut . mutate ( ) ; setToggleConfirm ( false ) ; } }
onCancel = { ( ) = > setToggleConfirm ( false ) }
/ >
< ConfirmDialog
open = { deleteOpen } title = "حذف پزشک"
message = { ` آیا از حذف دکتر " ${ doctor . name } " اطمینان دارید؟ این عمل قابل بازگشت نیست. ` }