@@ -14,6 +14,7 @@ import type { Appointment } from '../types';
import { formatRial } from '../lib/utils' ;
import Modal from './ui/Modal' ;
import PersianDateInput from './ui/PersianDateInput' ;
import PriceInput from './ui/PriceInput' ;
import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown' ;
/** Row actions for the appointments table (Figma عملیات menu). */
@@ -26,11 +27,32 @@ const toEpoch = (isoDate: string, time: string) =>
* Resolve the patient-record uuid behind an appointment via the patient list
* search (mobile is unique per user). Returns null when no record exists yet.
*/
async function findRecordUuid ( mobile : string ) : Promise < string | null > {
export async function findRecordUuid ( mobile : string ) : Promise < string | null > {
const res : any = await api . get ( ` /api/v1/patient?search= ${ encodeURIComponent ( mobile ) } &limit=1 ` ) ;
return res ? . data ? . [ 0 ] ? . uuid ? ? null ;
}
/**
* «شارژ کیف پول» accent link (appointment create/edit forms) — deep-links the
* patient's wallet tab, where the manual top-up modal lives.
*/
export function WalletChargeLink ( { mobile } : { mobile? : string } ) {
const navigate = useNavigate ( ) ;
const go = async ( ) = > {
if ( ! mobile || mobile . trim ( ) . length < 10 ) { toast . error ( 'ابتدا شماره تماس مراجعه کننده را وارد کنید' ) ; return ; }
try {
const recordUuid = await findRecordUuid ( mobile . trim ( ) ) ;
if ( ! recordUuid ) { toast . error ( 'پروندهای برای این بیمار یافت نشد' ) ; return ; }
navigate ( ` /admin/patients/ ${ recordUuid } ?tab=wallet ` ) ;
} catch { toast . error ( 'خطا در یافتن پرونده بیمار' ) ; }
} ;
return (
< button type = "button" className = "btn sm ghost" style = { { color : 'var(--accent)' } } onClick = { go } >
ش ا ر ژ ک ی ف پ و ل ‹
< / button >
) ;
}
export default function AppointmentActionsMenu ( { appointment , queryKey } : {
appointment : Appointment ; queryKey : unknown [ ] ;
} ) {
@@ -262,44 +284,167 @@ export function TransferReserveModal({ appointment: a, queryKey, onClose }: {
// ─────────────────────────────────────────────────────────────────────────────
// جایگزینی نوبت — put a different patient into the same slot
// (appointments-replace.pdf: patient search-or-new, بخش/سرویس, deposit,
// locked date/time, پرسنل, وضعیت, توضیحات)
// ─────────────────────────────────────────────────────────────────────────────
interface PickerOption { uuid : string ; name? : string ; full_name? : string }
interface PickedPatient { uuid : string ; user_name? : string ; user_mobile? : string }
export function ReplaceAppointmentModal ( { appointment : a , queryKey , onClose } : {
appointment : Appointment ; queryKey : unknown [ ] ; onClose : ( ) = > void ;
} ) {
const qc = useQueryClient ( ) ;
// patient: search an existing record or enter a new person
const [ patientSearch , setPatientSearch ] = useState ( '' ) ;
const [ picked , setPicked ] = useState < PickedPatient | null > ( null ) ;
const [ name , setName ] = useState ( '' ) ;
const [ mobile , setMobile ] = useState ( '' ) ;
const patientsQ = useQuery < ApiResponse < PickedPatient [ ] > > ( {
queryKey : [ 'replace-patients' , patientSearch ] ,
queryFn : ( ) = > api . get ( ` /api/v1/patient?search= ${ encodeURIComponent ( patientSearch ) } &limit=10 ` ) ,
enabled : patientSearch.trim ( ) . length >= 2 ,
} ) ;
// service specs + staff + status
const [ sectionUuid , setSectionUuid ] = useState ( a . service_section ? . uuid ? ? '' ) ;
const [ itemUuid , setItemUuid ] = useState ( a . service_item ? . uuid ? ? '' ) ;
const [ staffUuid , setStaffUuid ] = useState ( a . staff ? . uuid ? ? '' ) ;
const [ status , setStatus ] = useState ( a . status ) ;
const sectionsQ = useQuery < ApiResponse < PickerOption [ ] > > ( { queryKey : [ 'service-sections' ] , queryFn : ( ) = > api . get ( '/api/v1/service-sections' ) } ) ;
const itemsQ = useQuery < ApiResponse < PickerOption [ ] > > ( {
queryKey : [ 'service-items' , sectionUuid ] ,
queryFn : ( ) = > api . get ( ` /api/v1/service-items/ ${ sectionUuid } ` ) ,
enabled : ! ! sectionUuid ,
} ) ;
const staffQ = useQuery < ApiResponse < PickerOption [ ] > > ( { queryKey : [ 'staff-list' ] , queryFn : ( ) = > api . get ( '/api/v1/staff' ) } ) ;
// deposit
const [ depositRequired , setDepositRequired ] = useState ( ! ! a . deposit_required ) ;
const [ depositRials , setDepositRials ] = useState ( a . deposit_amount_rials ? ? 0 ) ;
const [ note , setNote ] = useState ( '' ) ;
const effectiveName = picked ? . user_name || name . trim ( ) ;
const effectiveMobile = picked ? . user_mobile || mobile . trim ( ) ;
const replace = useMutation ( {
mutationFn : ( ) = > api . patch ( ` /api/v1/appointment/ ${ a . uuid } ` , {
patient_name : name.trim ( ) , patient_mobile : mobile.trim ( ) ,
patient_name : effectiveName ,
patient_mobile : effectiveMobile ,
service_section_uuid : sectionUuid ,
service_item_uuid : itemUuid ,
staff_uuid : staffUuid ,
deposit_required : depositRequired ,
deposit_amount_rials : depositRequired ? depositRials : null ,
. . . ( note . trim ( ) ? { note : note.trim ( ) } : { } ) ,
. . . ( status !== a . status ? { status } : { } ) ,
version : a.version ,
} ) ,
onSuccess : ( ) = > { qc . invalidateQueries ( { queryKey } ) ; toast . success ( 'نوبت جایگزین شد' ) ; onClose ( ) ; } ,
onError : ( e : any ) = > toast . error ( e . message || 'خطا در جایگزینی نوبت' ) ,
} ) ;
const label = { fontSize : 12.5 , color : 'var(--text-3)' } as const ;
const sel = { width : '100%' , height : 38 , borderRadius : 'var(--r-sm)' , border : '1px solid var(--border)' , background : 'var(--surface)' , fontSize : 13 , fontFamily : 'inherit' , padding : '0 10px' } as const ;
const lockedField = { margin : '6px 0 12px' , opacity : 0.6 } as const ;
const patients = patientsQ . data ? . data ? ? [ ] ;
const statusOptions : [ string , string ] [ ] = [
[ 'pending' , 'ثبت شده' ] , [ 'confirmed' , 'قطعی شده' ] , [ 'following_up' , 'در حال پیگیری' ] ,
[ 'salon' , 'سالن' ] , [ 'completed' , 'ویزیت شده' ] , [ 'cancelled_by_doctor' , 'لغو شده' ] ,
] ;
return (
< Modal open title = "جایگزینی نوبت" onClose = { onClose } >
< div >
< label style = { { fontSize : 12.5 , color : 'var(--text-3)' } } > ن ا م و ن ا م خ ا ن و ا د گ ی < / label >
< div className = "field" style = { { margin : '6px 0 12 px' } } >
< input value = { name } onChange = { e = > setName ( e . target . value ) } placeholder = "نام و نام خانوادگی مراجعه کننده" / >
< label style = { label } > ا ن ت خ ا ب م ر ا ج ع ه ک ن ن د ه < / label >
< div className = "field" style = { { margin : '6px 0 8 px' } } >
< input value = { picked ? ` ${ picked . user_name ? ? '' } — ${ picked . user_mobile ? ? '' } ` : patientSearch }
onChange = { e = > { setPicked ( null ) ; setPatientSearch ( e . target . value ) ; } }
placeholder = "جستجوی نام، شماره تماس، شماره پرونده..." / >
< / div >
< label style = { { fontSize : 12.5 , color : 'var(--text-3)' } } > ش م ا ر ه ت م ا س < / label >
< div className = "field" style = { { margin : '6 px 0 12px ' } } >
< input value = { mobile } onChange = { e = > setMobile ( e . target . value ) } placeholder = "شماره تماس" dir = "ltr" / >
{ ! picked && patients . length > 0 && (
< div style = { { border : '1 px solid var(--border)' , borderRadius : 'var(--r-sm)' , marginBottom : 10 , overflow : 'hidden ' } } >
{ patients . map ( p = > (
< button key = { p . uuid } onClick = { ( ) = > setPicked ( p ) } style = { {
display : 'block' , width : '100%' , padding : '8px 10px' , fontSize : 13 , textAlign : 'right' ,
background : 'transparent' , border : 'none' , cursor : 'pointer' , fontFamily : 'inherit' ,
} } >
{ p . user_name } < span style = { { color : 'var(--text-3)' , direction : 'ltr' } } > { p . user_mobile } < / span >
< / button >
) ) }
< / div >
) }
{ picked === null && (
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 10 , marginBottom : 12 } } >
< div className = "field" > < input value = { name } onChange = { e = > setName ( e . target . value ) } placeholder = "نام و نام خانوادگی" / > < / div >
< div className = "field" > < input value = { mobile } onChange = { e = > setMobile ( e . target . value ) } placeholder = "شماره تماس" dir = "ltr" / > < / div >
< / div >
) }
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 10 , marginBottom : 12 } } >
< div >
< label style = { label } > ب خ ش < / label >
< select aria-label = "بخش" style = { { . . . sel , marginTop : 6 } } value = { sectionUuid } onChange = { e = > { setSectionUuid ( e . target . value ) ; setItemUuid ( '' ) ; } } >
< option value = "" > ا ن ت خ ا ب ب خ ش < / option >
{ ( sectionsQ . data ? . data ? ? [ ] ) . map ( o = > < option key = { o . uuid } value = { o . uuid } > { o . name } < / option > ) }
< / select >
< / div >
< div >
< label style = { label } > س ر و ی س < / label >
< select aria-label = "سرویس" style = { { . . . sel , marginTop : 6 } } value = { itemUuid } onChange = { e = > setItemUuid ( e . target . value ) } disabled = { ! sectionUuid } >
< option value = "" > ا ن ت خ ا ب ز ی ر ب خ ش < / option >
{ ( itemsQ . data ? . data ? ? [ ] ) . map ( o = > < option key = { o . uuid } value = { o . uuid } > { o . name } < / option > ) }
< / select >
< / div >
< / div >
< label style = { { fontSize : 12.5 , color : 'var(--text-3)' } } > ت و ض ی ح ا ت < / label >
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , gap : 10 , flexWrap : 'wrap' , marginBottom : 12 } } >
< label style = { { display : 'inline-flex' , alignItems : 'center' , gap : 8 , fontSize : 13 , cursor : 'pointer' } } >
< input type = "checkbox" checked = { depositRequired } onChange = { e = > setDepositRequired ( e . target . checked ) } / >
ب ی ع ا ن ه م و ر د ن ی ا ز ا س ت .
< / label >
{ depositRequired && < WalletChargeLink mobile = { effectiveMobile } / > }
< / div >
{ depositRequired && (
< div style = { { marginBottom : 12 } } >
< label style = { label } > م ب ل غ ب ی ع ا ن ه ( ت و م ا ن ) < / label >
< div style = { { marginTop : 6 } } > < PriceInput value = { depositRials } onChange = { setDepositRials } / > < / div >
< / div >
) }
{ /* the replacement keeps the original slot — date/time locked */ }
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 10 } } >
< div >
< label style = { label } > ا ن ت خ ا ب ت ا ر ی خ < / label >
< div className = "field" style = { lockedField } > < input value = { a . appointment_date } disabled dir = "ltr" / > < / div >
< / div >
< div >
< label style = { label } > س ا ع ت ش ر و ع < / label >
< div className = "field" style = { lockedField } > < input value = { a . appointment_time } disabled dir = "ltr" / > < / div >
< / div >
< / div >
< label style = { label } > ا ن ت خ ا ب پ ر س ن ل < / label >
< select aria-label = "پرسنل" style = { { . . . sel , margin : '6px 0 12px' } } value = { staffUuid } onChange = { e = > setStaffUuid ( e . target . value ) } >
< option value = "" > ا ن ت خ ا ب . . . < / option >
{ ( staffQ . data ? . data ? ? [ ] ) . map ( o = > < option key = { o . uuid } value = { o . uuid } > { o . full_name } < / option > ) }
< / select >
< label style = { label } > ا ن ت خ ا ب و ض ع ی ت < / label >
< select aria-label = "وضعیت" style = { { . . . sel , margin : '6px 0 12px' } } value = { status } onChange = { e = > setStatus ( e . target . value as Appointment [ 'status' ] ) } >
{ statusOptions . map ( ( [ v , l ] ) = > < option key = { v } value = { v } > { l } < / option > ) }
< / select >
< label style = { label } > ت و ض ی ح ا ت < / label >
< div className = "field" style = { { height : 'auto' , margin : '6px 0 16px' } } >
< textarea value = { note } onChange = { e = > setNote ( e . target . value ) } rows = { 3 } placeholder = "توضیحات..."
style = { { width : '100%' , border : 'none' , background : 'transparent' , fontFamily : 'inherit' , resize : 'vertical' } } / >
< / div >
< button className = "btn primary" style = { { width : '100%' } }
disabled = { name . trim ( ) . length < 2 || mobile . trim ( ) . length < 10 || replace . isPending }
disabled = { effectiveName . length < 2 || effectiveMobile . length < 10 || replace . isPending }
onClick = { ( ) = > replace . mutate ( ) } >
ث ب ت ن و ب ت
< / button >