@@ -1,84 +1,594 @@
import React , { useEffect , useState } from 'react' ;
import React , { useState , useEffect , useMemo , useRef } from 'react' ;
import { createPortal } from 'react-dom' ;
import { useParams , useNavigate } from 'react-router-dom' ;
import { useParams , useNavigate } 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 { zodResolver } from '@hookform/resolvers/zod' ;
import { z } from 'zod' ;
import {
import {
ArrowRightIcon ,
ArrowRightIcon , PencilIcon , TrashIcon ,
BuildingOffice2Icon ,
BuildingOffice2Icon , PhoneIcon , MapPinIcon , XMarkIcon ,
PencilIcon ,
PlusIcon , CameraIcon , ChevronDownIcon ,
CheckIcon ,
XMarkIcon ,
} from '@heroicons/react/24/outline' ;
} from '@heroicons/react/24/outline' ;
import { MapContainer , TileLayer , Marker , useMapEvents , useMap } from 'react-leaflet' ;
import L from 'leaflet' ;
import 'leaflet/dist/leaflet.css' ;
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 { formatDate } from '../lib/util s' ;
import type { ClinicDetail } from '../type s' ;
import { formatNumber } from '../lib/utils' ;
import ConfirmDialog from '../components/ui/ConfirmDialog' ;
// Fix leaflet icons
delete ( L . Icon . Default . prototype as any ) . _getIconUrl ;
L . Icon . Default . mergeOptions ( {
iconRetinaUrl : 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png' ,
iconUrl : 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png' ,
shadowUrl : 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png' ,
} ) ;
const HUES_LIST = [ 256 , 205 , 162 , 295 , 272 ] ;
const HUES_LIST = [ 256 , 205 , 162 , 295 , 272 ] ;
const IRAN_CENTER : [ number , number ] = [ 32.4279 , 53.6880 ] ;
interface ClinicDetail {
// ── Types ──────────────────────────────────────────────────────────────────
uuid : string ;
name : string ;
interface ClinicDoctorItem {
is_active : boolean ;
id : string ; uuid : string ; name : string ;
phone : string | null ;
gender : string | null ; degree : string | null ;
logo : string | null ;
img : { url : string } [ ] ;
doctors_count? : number ;
specialties : { id : string ; name : string } [ ] ;
created_at? : number ;
active : boolean ;
}
}
interface Opt { id : number ; name : string ; }
interface OptUuid { id : number ; uuid : string ; name : string ; }
// ── Edit form schema ───────────────────────────────────────────────────────
const editSchema = z . object ( {
name : z.string ( ) . min ( 1 , 'نام الزامی است' ) ,
telephone : z.string ( ) . optional ( ) ,
info : z.string ( ) . optional ( ) ,
is_247 : z.boolean ( ) ,
address : z.string ( ) . optional ( ) ,
province_id : z.number ( ) . nullable ( ) ,
city_id : z.number ( ) . nullable ( ) ,
latitude : z.string ( ) . optional ( ) ,
longitude : z.string ( ) . optional ( ) ,
specialties : z.array ( z . number ( ) ) ,
insurance : z.array ( z . number ( ) ) ,
doctor_services : z.array ( z . number ( ) ) ,
} ) ;
type EditForm = z . infer < typeof editSchema > ;
// ── Searchable Select (template CSS) ──────────────────────────────────────
function SearchableSelectField ( { options , value , onChange , placeholder , disabled = false } : {
options : Opt [ ] ;
value : number | null ;
onChange : ( v : number | null , label? : string ) = > void ;
placeholder? : string ;
disabled? : boolean ;
} ) {
const [ open , setOpen ] = useState ( false ) ;
const [ q , setQ ] = useState ( '' ) ;
const [ rect , setRect ] = useState < DOMRect | null > ( null ) ;
const btnRef = useRef < HTMLButtonElement > ( null ) ;
const dropRef = useRef < HTMLDivElement > ( null ) ;
const filtered = useMemo (
( ) = > q ? options . filter ( o = > o . name . includes ( q ) ) : options ,
[ options , q ] ,
) ;
const selected = useMemo ( ( ) = > options . find ( o = > o . id === value ) ? ? null , [ options , value ] ) ;
const openDD = ( ) = > {
if ( disabled || ! btnRef . current ) return ;
setRect ( btnRef . current . getBoundingClientRect ( ) ) ;
setOpen ( v = > ! v ) ;
setQ ( '' ) ;
} ;
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 dropStyle : React.CSSProperties = rect
? { position : 'fixed' , top : rect.bottom + 4 , left : rect.left , width : rect.width , zIndex : 9999 }
: { } ;
return (
< div >
< button ref = { btnRef } type = "button" disabled = { disabled } onClick = { openDD }
style = { {
width : '100%' , textAlign : 'right' , display : 'flex' , alignItems : 'center' ,
justifyContent : 'space-between' , padding : '8px 12px' , borderRadius : 8 ,
border : '1px solid var(--border)' , background : disabled ? 'var(--surface-2, var(--bg))' : 'var(--surface)' ,
color : selected ? 'var(--text)' : 'var(--text-3)' , cursor : disabled ? 'not-allowed' : 'pointer' ,
fontSize : 14 , opacity : disabled ? 0.6 : 1 ,
} } >
< span > { selected ? . name ? ? placeholder ? ? 'انتخاب کنید' } < / span >
< ChevronDownIcon style = { { width : 14 , height : 14 , flexShrink : 0 , transform : open ? 'rotate(180deg)' : 'none' , transition : 'transform .15s' } } / >
< / button >
{ open && createPortal (
< div ref = { dropRef } style = { {
. . . dropStyle ,
background : 'var(--surface)' , border : '1px solid var(--border)' ,
borderRadius : 10 , boxShadow : '0 8px 32px rgba(0,0,0,.12)' , overflow : 'hidden' ,
} } >
< div style = { { padding : '8px 8px 0' } } >
< input autoFocus value = { q } onChange = { e = > setQ ( e . target . value ) } placeholder = "جستجو..."
className = "input" style = { { fontSize : 13 } } / >
< / div >
< div style = { { maxHeight : 200 , overflowY : 'auto' , padding : '4px 0' } } >
< button type = "button"
onClick = { ( ) = > { onChange ( null ) ; setOpen ( false ) ; } }
style = { { width : '100%' , textAlign : 'right' , padding : '8px 12px' , fontSize : 13 , color : 'var(--text-3)' , background : 'none' , border : 'none' , cursor : 'pointer' } } >
{ placeholder ? ? 'انتخاب کنید' }
< / button >
{ filtered . map ( o = > (
< button key = { o . id } type = "button"
onClick = { ( ) = > { onChange ( o . id , o . name ) ; setOpen ( false ) ; setQ ( '' ) ; } }
style = { {
width : '100%' , textAlign : 'right' , padding : '8px 12px' , fontSize : 13 ,
background : value === o . id ? 'var(--primary-light, oklch(0.95 0.04 256))' : 'none' ,
color : value === o . id ? 'var(--primary)' : 'var(--text)' , border : 'none' , cursor : 'pointer' ,
} } >
{ o . name }
< / button >
) ) }
{ filtered . length === 0 && (
< p style = { { textAlign : 'center' , padding : '10px 0' , fontSize : 12 , color : 'var(--text-3)' } } > ن ت ی ج ه ا ی ی ا ف ت ن ش د < / p >
) }
< / div >
< / div > ,
document . body ,
) }
< / div >
) ;
}
// ── Multi-select checkbox list ─────────────────────────────────────────────
function MultiCheckList ( { options , selected , onChange , placeholder } : {
options : Opt [ ] ; selected : number [ ] ; onChange : ( v : number [ ] ) = > void ; placeholder? : string ;
} ) {
const [ q , setQ ] = useState ( '' ) ;
const filtered = useMemo ( ( ) = > q ? options . filter ( o = > o . name . includes ( q ) ) : options , [ options , q ] ) ;
const toggle = ( id : number ) = >
onChange ( selected . includes ( id ) ? selected . filter ( x = > x !== id ) : [ . . . selected , id ] ) ;
return (
< div style = { { border : '1px solid var(--border)' , borderRadius : 8 , overflow : 'hidden' } } >
< div style = { { padding : '6px 8px' , borderBottom : '1px solid var(--border)' , background : 'var(--surface-2, var(--bg))' } } >
< input value = { q } onChange = { e = > setQ ( e . target . value ) } placeholder = { placeholder ? ? 'جستجو...' }
className = "input" style = { { fontSize : 13 } } / >
< / div >
{ selected . length > 0 && (
< div style = { { padding : '6px 10px' , borderBottom : '1px solid var(--border)' , display : 'flex' , flexWrap : 'wrap' , gap : 4 } } >
{ selected . map ( id = > {
const opt = options . find ( o = > o . id === id ) ;
if ( ! opt ) return null ;
return (
< span key = { id } className = "badge violet" style = { { cursor : 'pointer' , userSelect : 'none' } }
onClick = { ( ) = > toggle ( id ) } >
< span className = "bdot" / > { opt . name } ×
< / span >
) ;
} ) }
< / div >
) }
< div style = { { maxHeight : 180 , overflowY : 'auto' } } >
{ filtered . length === 0
? < p style = { { textAlign : 'center' , padding : '10px 0' , fontSize : 12 , color : 'var(--text-3)' } } > ن ت ی ج ه ا ی ی ا ف ت ن ش د < / p >
: filtered . map ( o = > (
< label key = { o . id } style = { { display : 'flex' , alignItems : 'center' , gap : 8 , padding : '7px 12px' , cursor : 'pointer' } } >
< input type = "checkbox" checked = { selected . includes ( o . id ) } onChange = { ( ) = > toggle ( o . id ) } / >
< span style = { { fontSize : 13 , color : 'var(--text)' } } > { o . name } < / span >
< / label >
) ) }
< / div >
< / div >
) ;
}
// ── Leaflet helpers ────────────────────────────────────────────────────────
function MapClickHandler ( { onPick } : { onPick : ( lat : number , lng : number ) = > void } ) {
useMapEvents ( { click : e = > onPick ( e . latlng . lat , e . latlng . lng ) } ) ;
return null ;
}
function MapFlyController ( { target } : { target : [ number , number ] | null } ) {
const map = useMap ( ) ;
useEffect ( ( ) = > { if ( target ) map . flyTo ( target , 12 , { duration : 1.2 } ) ; } , [ target , map ] ) ;
return null ;
}
function MapPicker ( { lat , lng , onChange , flyTarget } : {
lat : number | null ; lng : number | null ;
onChange : ( lat : number , lng : number ) = > void ;
flyTarget ? : [ number , number ] | null ;
} ) {
const pos : [ number , number ] | null = lat !== null && lng !== null ? [ lat , lng ] : null ;
const center : [ number , number ] = pos ? ? IRAN_CENTER ;
return (
< div style = { { borderRadius : 10 , overflow : 'hidden' , border : '1px solid var(--border)' , height : 260 } } >
< MapContainer center = { center } zoom = { pos ? 13 : 5 } style = { { height : '100%' , width : '100%' } } >
< TileLayer url = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution = '© <a href="https://openstreetmap.org">OpenStreetMap</a>' / >
< MapClickHandler onPick = { onChange } / >
< MapFlyController target = { flyTarget ? ? null } / >
{ pos && < Marker position = { pos } / > }
< / MapContainer >
< / div >
) ;
}
// ── Geocode helper ─────────────────────────────────────────────────────────
async function geocodeCity ( name : string ) : Promise < [ number , number ] | null > {
try {
const url = ` https://nominatim.openstreetmap.org/search?q= ${ encodeURIComponent ( name + ',ایران' ) } &format=json&countrycodes=ir&limit=1 ` ;
const res = await fetch ( url , { headers : { 'Accept-Language' : 'fa' } } ) ;
const d = await res . json ( ) ;
if ( d ? . [ 0 ] ) return [ parseFloat ( d [ 0 ] . lat ) , parseFloat ( d [ 0 ] . lon ) ] ;
return null ;
} catch { return null ; }
}
// ── Edit Modal ─────────────────────────────────────────────────────────────
function EditModal ( { clinic , onClose , onSaved } : {
clinic : ClinicDetail ; onClose : ( ) = > void ; onSaved : ( ) = > void ;
} ) {
const [ mapFlyTarget , setMapFlyTarget ] = useState < [ number , number ] | null > ( null ) ;
const { register , handleSubmit , watch , setValue , formState : { errors } } = useForm < EditForm > ( {
resolver : zodResolver ( editSchema ) ,
defaultValues : {
name : clinic.name ? ? '' ,
telephone : clinic.phone ? ? clinic . phone_number ? ? '' ,
info : clinic.caption ? ? '' ,
is_247 : clinic [ '24_7' ] ? ? false ,
address : clinic.location ? ? '' ,
province_id : clinic.state?. [ 0 ] ? Number ( clinic . state [ 0 ] . id ) : null ,
city_id : clinic.city?. [ 0 ] ? Number ( clinic . city [ 0 ] . id ) : null ,
latitude : clinic.map?.latitude ? ? '' ,
longitude : clinic.map?.longitude ? ? '' ,
specialties : ( clinic . specialties ? ? [ ] ) . map ( s = > Number ( s . id ) ) ,
insurance : ( clinic . list_bime ? ? [ ] ) . map ( s = > Number ( s . id ) ) ,
doctor_services : ( clinic . services ? ? [ ] ) . map ( s = > Number ( s . id ) ) ,
} ,
} ) ;
const provinceId = watch ( 'province_id' ) ;
const cityId = watch ( 'city_id' ) ;
const lat = watch ( 'latitude' ) ;
const lng = watch ( 'longitude' ) ;
const latN = lat ? parseFloat ( lat ) : null ;
const lngN = lng ? parseFloat ( lng ) : null ;
const watchedSpec = ( watch ( 'specialties' ) ? ? [ ] ) as number [ ] ;
const watchedIns = ( watch ( 'insurance' ) ? ? [ ] ) as number [ ] ;
const watchedSrv = ( watch ( 'doctor_services' ) ? ? [ ] ) as number [ ] ;
const provincesQ = useQuery ( {
queryKey : [ 'provinces' ] , staleTime : 600_000 ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( '/api/v1/provinces' ) ,
} ) ;
const citiesQ = useQuery ( {
queryKey : [ 'cities' , provinceId ] , staleTime : 300_000 ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( ` /api/v1/cities?province_id= ${ provinceId } ` ) ,
enabled : ! ! provinceId ,
} ) ;
const specialtiesQ = useQuery ( {
queryKey : [ 'specialties-all' ] , staleTime : 600_000 ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( '/api/v1/specialties' ) ,
} ) ;
const insurancesQ = useQuery ( {
queryKey : [ 'insurances-all' ] , staleTime : 600_000 ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( '/api/v1/insurances' ) ,
} ) ;
const servicesQ = useQuery ( {
queryKey : [ 'doctor-services-all' ] , staleTime : 600_000 ,
queryFn : ( ) = > api . get < ApiResponse < any > > ( '/api/v1/doctor-services' ) ,
} ) ;
const provinces : Opt [ ] = useMemo ( ( ) = > ( provincesQ . data ? . data ? . data ? ? provincesQ . data ? . data ? ? [ ] ) . map ( ( p : any ) = > ( { id : Number ( p . id ? ? p . nid ) , name : p.name } ) ) , [ provincesQ . data ] ) ;
const cities : Opt [ ] = useMemo ( ( ) = > ( citiesQ . data ? . data ? . data ? ? citiesQ . data ? . data ? ? [ ] ) . map ( ( c : any ) = > ( { id : Number ( c . id ? ? c . nid ) , name : c.name } ) ) , [ citiesQ . data ] ) ;
const specialties : Opt [ ] = useMemo ( ( ) = > {
const raw = specialtiesQ . data ? . data ? . data ? ? specialtiesQ . data ? . data ? ? [ ] ;
return raw . map ( ( s : any ) = > ( { id : Number ( s . id ) , name : s.name } ) ) ;
} , [ specialtiesQ . data ] ) ;
const insurances : Opt [ ] = useMemo ( ( ) = > {
const raw = insurancesQ . data ? . data ? . data ? ? insurancesQ . data ? . data ? ? [ ] ;
return raw . map ( ( i : any ) = > ( { id : Number ( i . id ) , name : i.name } ) ) ;
} , [ insurancesQ . data ] ) ;
const services : Opt [ ] = useMemo ( ( ) = > {
const raw = servicesQ . data ? . data ? . data ? ? servicesQ . data ? . data ? ? [ ] ;
return raw . map ( ( s : any ) = > ( { id : Number ( s . id ) , name : s.name } ) ) ;
} , [ servicesQ . data ] ) ;
const saveMut = useMutation ( {
mutationFn : ( values : EditForm ) = > api . patch < ApiResponse < any > > ( ` /api/v1/clinic/ ${ clinic . uuid } ` , {
name : values.name ,
telephone : values.telephone || undefined ,
info : values.info || undefined ,
'24_7' : values . is_247 ,
address : values.address || undefined ,
state : values.province_id ? [ values . province_id ] : undefined ,
city : values.city_id ? [ values . city_id ] : undefined ,
latitude : values.latitude ? parseFloat ( values . latitude ) : undefined ,
longitude : values.longitude ? parseFloat ( values . longitude ) : undefined ,
specialties : values.specialties ,
insurance : values.insurance ,
doctor_services : values.doctor_services ,
} ) ,
onSuccess : ( ) = > { toast . success ( 'اطلاعات کلینیک ذخیره شد' ) ; onSaved ( ) ; onClose ( ) ; } ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
const [ activeTab , setActiveTab ] = useState < 'basic' | 'location' | 'tags' > ( 'basic' ) ;
return (
< div className = "overlay" onClick = { onClose } >
< div className = "modal" style = { { maxWidth : 560 , maxHeight : '90vh' , overflowY : 'auto' } } onClick = { e = > e . stopPropagation ( ) } >
< div className = "modal-head" >
< b > و ی ر ا ی ش ک ل ی ن ی ک < / b >
< button className = "mini-btn" onClick = { onClose } >
< XMarkIcon style = { { width : 16 , height : 16 } } / >
< / button >
< / div >
{ /* Tab switcher */ }
< div style = { { borderBottom : '1px solid var(--border)' , padding : '0 16px' , display : 'flex' , gap : 4 } } >
{ ( [ [ 'basic' , 'اطلاعات پایه' ] , [ 'location' , 'موقعیت' ] , [ 'tags' , 'تخصص و بیمه' ] ] as const ) . map ( ( [ id , label ] ) = > (
< button key = { id } type = "button" onClick = { ( ) = > setActiveTab ( id ) }
style = { {
padding : '10px 14px' , fontSize : 13 , fontWeight : 600 , border : 'none' , background : 'none' ,
cursor : 'pointer' , borderBottom : activeTab === id ? '2px solid var(--primary)' : '2px solid transparent' ,
color : activeTab === id ? 'var(--primary)' : 'var(--text-3)' , marginBottom : - 1 ,
} } >
{ label }
< / button >
) ) }
< / div >
< form onSubmit = { handleSubmit ( v = > saveMut . mutate ( v ) ) } >
< div className = "modal-body" style = { { display : 'flex' , flexDirection : 'column' , gap : 14 } } >
{ /* ── Basic tab ── */ }
{ activeTab === 'basic' && (
< >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } > ن ا م ک ل ی ن ی ک < / label >
< input className = "input" { ...register ( 'name' ) } / >
{ errors . name && < div className = "err-text" > { errors . name . message } < / div > }
< / div >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } > ت ل ف ن < / label >
< input className = "input" dir = "ltr" placeholder = "021-12345678" { ...register ( 'telephone' ) } / >
< / div >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } > ت و ض ی ح ا ت < / label >
< textarea className = "input" rows = { 3 } style = { { resize : 'vertical' } } { ...register ( 'info' ) } / >
< / div >
< label style = { { display : 'flex' , alignItems : 'center' , gap : 10 , cursor : 'pointer' , userSelect : 'none' } } >
< input type = "checkbox" { ...register ( 'is_247' ) } / >
< span style = { { fontSize : 13 , fontWeight : 600 } } > ک ل ی ن ی ک ۲ ۴ س ا ع ت ه ( ۷ ر و ز ه ف ت ه ) < / span >
< / label >
< / >
) }
{ /* ── Location tab ── */ }
{ activeTab === 'location' && (
< >
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } > ا س ت ا ن < / label >
< SearchableSelectField
options = { provinces }
value = { provinceId ? ? null }
placeholder = "انتخاب استان"
onChange = { val = > { setValue ( 'province_id' , val ) ; setValue ( 'city_id' , null ) ; } }
/ >
< / div >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } > ش ه ر < / label >
< SearchableSelectField
options = { cities }
value = { cityId ? ? null }
placeholder = { provinceId ? 'انتخاب شهر' : 'ابتدا استان انتخاب کنید' }
disabled = { ! provinceId }
onChange = { ( val , label ) = > {
setValue ( 'city_id' , val ) ;
if ( label ) geocodeCity ( label ) . then ( c = > { if ( c ) setMapFlyTarget ( c ) ; } ) ;
} }
/ >
< / div >
< / div >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } > آ د ر س < / label >
< textarea className = "input" rows = { 2 } style = { { resize : 'none' } } placeholder = "خیابان، کوچه، پلاک..." { ...register ( 'address' ) } / >
< / div >
< div >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : 6 } } >
< label style = { { fontSize : 13 , fontWeight : 600 } } > م و ق ع ی ت ر و ی ن ق ش ه < / label >
{ latN && lngN && (
< span className = "muted" style = { { fontSize : 11 , direction : 'ltr' } } > { latN . toFixed ( 4 ) } , { lngN . toFixed ( 4 ) } < / span >
) }
< / div >
< p className = "muted" style = { { fontSize : 12 , marginBottom : 6 } } > ب ر ا ی ت ع ی ی ن م و ق ع ی ت ر و ی ن ق ش ه ک ل ی ک ک ن ی د < / p >
< MapPicker
lat = { latN } lng = { lngN } flyTarget = { mapFlyTarget }
onChange = { ( lt , ln ) = > { setValue ( 'latitude' , String ( lt ) ) ; setValue ( 'longitude' , String ( ln ) ) ; } }
/ >
{ latN && lngN && (
< button type = "button" className = "btn ghost sm" style = { { marginTop : 6 , fontSize : 12 } }
onClick = { ( ) = > { setValue ( 'latitude' , '' ) ; setValue ( 'longitude' , '' ) ; } } >
< XMarkIcon style = { { width : 13 , height : 13 } } / > ح ذ ف م و ق ع ی ت
< / button >
) }
< / div >
< / >
) }
{ /* ── Tags tab ── */ }
{ activeTab === 'tags' && (
< >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } >
ت خ ص ص ه ا ( { watchedSpec . length } ا ن ت خ ا ب ش د ه )
< / label >
< MultiCheckList options = { specialties } selected = { watchedSpec }
onChange = { v = > setValue ( 'specialties' , v ) } placeholder = "جستجوی تخصص..." / >
< / div >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } >
ب ی م ه ه ا ( { watchedIns . length } ا ن ت خ ا ب ش د ه )
< / label >
< MultiCheckList options = { insurances } selected = { watchedIns }
onChange = { v = > setValue ( 'insurance' , v ) } placeholder = "جستجوی بیمه..." / >
< / div >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , display : 'block' , marginBottom : 6 } } >
خ د م ا ت ( { watchedSrv . length } ا ن ت خ ا ب ش د ه )
< / label >
< MultiCheckList options = { services } selected = { watchedSrv }
onChange = { v = > setValue ( 'doctor_services' , v ) } placeholder = "جستجوی خدمت..." / >
< / div >
< / >
) }
< / div >
< div className = "modal-foot" >
< button type = "button" className = "btn ghost sm" onClick = { onClose } > ا ن ص ر ا ف < / button >
< button type = "submit" className = "btn primary sm" disabled = { saveMut . isPending } >
{ saveMut . isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات' }
< / button >
< / div >
< / form >
< / div >
< / div >
) ;
}
// ── Main Page ──────────────────────────────────────────────────────────────
export default function ClinicDetailPage() {
export default function ClinicDetailPage() {
const { uuid } = useParams < { uuid : string } > ( ) ;
const { uuid } = useParams < { uuid : string } > ( ) ;
const navigate = useNavigate ( ) ;
const navigate = useNavigate ( ) ;
const qc = useQueryClient ( ) ;
const qc = useQueryClient ( ) ;
const [ editOpen , setEditOpen ] = useState ( false ) ;
const [ editOpen , setEditOpen ] = useState ( false ) ;
const [ editName , setEditName ] = useState ( '' ) ;
const [ deleteOpen , setDeleteOpen ] = useState ( false ) ;
const [ editPhone , setEditPhone ] = useState ( '' ) ;
const logoInputRef = useRef < HTMLInputElement > ( null ) ;
const galleryInputRef = useRef < HTMLInputElement > ( null ) ;
const [ logoUploading , setLogoUploading ] = useState ( false ) ;
const [ galleryUploading , setGalleryUploading ] = useState ( false ) ;
const { data , isLoading } = useQuery ( {
const { data , isLoading } = useQuery ( {
queryKey : [ 'clinic-detail' , uuid ] ,
queryKey : [ 'clinic-detail' , uuid ] ,
queryFn : ( ) = > api . get < ApiResponse < ClinicDetail > > ( ` /api/v1/clinic/ ${ uuid } ` ) ,
queryFn : ( ) = > api . get < ApiResponse < { data : ClinicDetail } > > ( ` /api/v1/clinic/ ${ uuid } ` ) ,
enabled : ! ! uuid ,
enabled : ! ! uuid ,
} ) ;
} ) ;
const clinic : ClinicDetail | undefined = data ? . data ;
const doctorsQ = useQuery ( {
queryKey : [ 'clinic-doctors' , uuid ] ,
queryFn : ( ) = > api . get < ApiResponse < { data : ClinicDoctorItem [ ] } > > ( ` /api/v1/clinic/doctor-list/ ${ uuid } ` ) ,
enabled : ! ! uuid ,
} ) ;
useEffect ( ( ) = > {
const clinic : ClinicDetail | undefined = useMemo ( ( ) = > {
if ( clinic ) {
const raw = data ? . data ;
setEditName ( clinic . name ? ? '' ) ;
return ( raw as any ) ? . data ? ? raw ;
setEditPhone ( clinic . phone ? ? '' ) ;
} , [ data ] ) ;
}
} , [ clinic ] ) ;
const toggleMutation = useMutation ( {
const doctorList : ClinicDoctorItem [ ] = useMemo ( ( ) = > {
mutationFn : ( ) = > api . patch < ApiResponse < { is_active : boolean } > > ( ` /api/v1/admin/clinic/ ${ uuid } /status ` , { } ) ,
const raw = doctorsQ . data ? . data ;
return ( raw as any ) ? . data ? ? raw ? ? [ ] ;
} , [ doctorsQ . data ] ) ;
const toggleMut = useMutation ( {
mutationFn : ( ) = > api . patch < ApiResponse < any > > ( ` /api/v1/admin/clinic/ ${ uuid } /status ` , { } ) ,
onSuccess : ( ) = > {
onSuccess : ( ) = > {
toast . success ( 'وضعیت کلینیک تغییر کرد' ) ;
toast . success ( 'وضعیت کلینیک تغییر کرد' ) ;
qc . invalidateQueries ( { queryKey : [ 'clinic-detail' , uuid ] } ) ;
qc . invalidateQueries ( { queryKey : [ 'clinic-detail' , uuid ] } ) ;
qc . invalidateQueries ( { queryKey : [ 'admin-clinics' ] } ) ;
qc . invalidateQueries ( { queryKey : [ 'admin-clinics' ] } ) ;
} ,
} ,
onError : ( err : Error ) = > toast . error ( err . message ) ,
onError : ( e : Error ) = > toast . error ( e . message ) ,
} ) ;
} ) ;
const updateMutation = useMutation ( {
const deleteMut = useMutation ( {
mutationFn : ( d : { name : string ; phone? : string } ) = >
mutationFn : ( ) = > api . delete < ApiResponse < null > > ( ` /api/v1/admin/clinic/ ${ uuid } ` ) ,
api . patch < ApiResponse < ClinicDetail > > ( ` /api/v1/clinic/ ${ uuid } ` , d ) ,
onSuccess : ( ) = > { toast . success ( 'کلینیک حذف شد' ) ; navigate ( '/admin/clinics' ) ; } ,
onSuccess : ( ) = > {
onError : ( e : Error ) = > toast . error ( e . message ) ,
toast . success ( 'اطلاعات کلینیک ذخیره شد' ) ;
setEditOpen ( false ) ;
qc . invalidateQueries ( { queryKey : [ 'clinic-detail' , uuid ] } ) ;
qc . invalidateQueries ( { queryKey : [ 'admin-clinics' ] } ) ;
} ,
onError : ( err : Error ) = > toast . error ( err . message ) ,
} ) ;
} ) ;
const handleLogoUpload = async ( file : File ) = > {
setLogoUploading ( true ) ;
try {
const res = await fetch ( '/file/upload/clinic_pro/clinic/field_clinic_logo' , {
method : 'POST' ,
headers : {
'Content-Disposition' : ` filename=" ${ file . name } " ` ,
'Content-Type' : file . type || 'application/octet-stream' ,
Authorization : ` Bearer ${ JSON . parse ( localStorage . getItem ( 'clinicpro-auth' ) ? ? '{}' ) ? . state ? . token ? ? '' } ` ,
} ,
body : file ,
} ) ;
const json = await res . json ( ) ;
const url = json ? . data ? . url ;
if ( url ) {
await api . patch ( ` /api/v1/clinic/ ${ uuid } ` , { clinic_logo : url } ) ;
toast . success ( 'لوگو آپلود شد' ) ;
qc . invalidateQueries ( { queryKey : [ 'clinic-detail' , uuid ] } ) ;
}
} catch ( e : any ) { toast . error ( e . message ) ; }
finally { setLogoUploading ( false ) ; }
} ;
const handleGalleryUpload = async ( file : File ) = > {
setGalleryUploading ( true ) ;
try {
const res = await fetch ( '/file/upload/clinic_pro/clinic/field_image_clinic' , {
method : 'POST' ,
headers : {
'Content-Disposition' : ` filename=" ${ file . name } " ` ,
'Content-Type' : file . type || 'application/octet-stream' ,
Authorization : ` Bearer ${ JSON . parse ( localStorage . getItem ( 'clinicpro-auth' ) ? ? '{}' ) ? . state ? . token ? ? '' } ` ,
} ,
body : file ,
} ) ;
const json = await res . json ( ) ;
const url = json ? . data ? . url ;
if ( url && clinic ) {
const existing = ( clinic . images_clinic ? ? [ ] ) . map ( img = > img . url ) ;
await api . patch ( ` /api/v1/clinic/ ${ uuid } ` , { image_clinic : [ . . . existing , url ] } ) ;
toast . success ( 'تصویر اضافه شد' ) ;
qc . invalidateQueries ( { queryKey : [ 'clinic-detail' , uuid ] } ) ;
}
} catch ( e : any ) { toast . error ( e . message ) ; }
finally { setGalleryUploading ( false ) ; }
} ;
const hue = HUES_LIST [ ( uuid ? . charCodeAt ( 0 ) ? ? 0 ) % HUES_LIST . length ] ;
const hue = HUES_LIST [ ( uuid ? . charCodeAt ( 0 ) ? ? 0 ) % HUES_LIST . length ] ;
const logo = clinic ? . logo ? ? clinic ? . clinic_logo ;
const lat = clinic ? . map ? . latitude ? parseFloat ( clinic . map . latitude ) : null ;
const lng = clinic ? . map ? . longitude ? parseFloat ( clinic . map . longitude ) : null ;
const cityName = clinic ? . city ? . [ 0 ] ? . name ;
const provinceName = clinic ? . state ? . [ 0 ] ? . name ;
if ( isLoading ) {
if ( isLoading ) {
return (
return (
< div className = "fade-in" >
< div className = "fade-in" >
< div className = "card card-pad" >
< div className = "card card-pad" >
{ Array . from ( { length : 5 } ) . map ( ( _ , i ) = > (
{ Array . from ( { length : 6 } ) . map ( ( _ , i ) = > (
< div key = { i } className = "skeleton" style = { { height : 22 , borderRadius : 6 , marginBottom : 12 } } / >
< div key = { i } className = "skeleton" style = { { height : 20 , borderRadius : 6 , marginBottom : 12 } } / >
) ) }
) ) }
< / div >
< / div >
< / div >
< / div >
@@ -100,14 +610,11 @@ export default function ClinicDetailPage() {
return (
return (
< div className = "fade-in" >
< div className = "fade-in" >
{ /* Header */ }
{ /* ── Header ── */ }
< div className = "card-title-row" style = { { marginBottom : 'var(--gap)' } } >
< div className = "card-title-row" style = { { marginBottom : 'var(--gap)' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 10 } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 10 } } >
< button
< button className = "btn ghost sm" onClick = { ( ) = > navigate ( '/admin/clinics' ) } style = { { padding : '6px 10px' } } >
className = "btn ghost sm"
onClick = { ( ) = > navigate ( '/admin/clinics' ) }
style = { { padding : '6px 10px' } }
>
< ArrowRightIcon style = { { width : 16 , height : 16 } } / >
< ArrowRightIcon style = { { width : 16 , height : 16 } } / >
< / button >
< / button >
< div >
< div >
@@ -116,136 +623,244 @@ export default function ClinicDetailPage() {
< / div >
< / div >
< / div >
< / div >
< div style = { { display : 'flex' , gap : 8 } } >
< div style = { { display : 'flex' , gap : 8 } } >
< button
< button className = "btn ghost sm" onClick = { ( ) = > setEditOpen ( true ) } >
className = { ` btn sm ${ clinic . is_active ? 'soft' : 'primary' } ` }
< PencilIcon style = { { width : 15 , height : 15 } } / > و ی ر ا ی ش
onClick = { ( ) = > toggleMutation . mutate ( ) }
< / button >
disabled = { toggleMutation . isPending }
< button className = { ` btn sm ${ clinic . is_active ? 'soft' : 'primary' } ` }
>
onClick = { ( ) = > toggleMut . mutate ( ) } disabled = { toggleMut . isPending } >
{ clinic . is_active ? 'غیرفعال کردن' : 'فعال کردن' }
{ clinic . is_active ? 'غیرفعال کردن' : 'فعال کردن' }
< / button >
< / button >
< button className = "btn ghost sm" onClick = { ( ) = > setEditOpen ( true ) } >
< button className = "btn danger sm" onClick = { ( ) = > setDeleteOpen ( true ) } >
< PencilIcon style = { { width : 15 , height : 15 } } / >
< TrashIcon style = { { width : 15 , height : 15 } } / > ح ذ ف
و ی ر ا ی ش
< / button >
< / button >
< / div >
< / div >
< / div >
< / div >
{ /* Main info card */ }
{ /* ── Main grid ── */ }
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 300px' , gap : 'var(--gap)' , alignItems : 'start' } } >
{ /* Left column */ }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 'var(--gap)' } } >
{ /* Identity card */ }
< div className = "card card-pad" >
< div className = "card card-pad" >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 16 , marginBottom : 20 } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 16 , marginBottom : 20 } } >
{ clinic . logo ? (
< div style = { { position : 'relative' , flexShrink : 0 } } >
< img
{ logo ? (
src = { clinic . logo }
< img src = { logo } alt = "" className = "avatar lg" style = { { objectFit : 'cover' } } / >
alt = ""
className = "avatar lg"
style = { { objectFit : 'cover' } }
/ >
) : (
) : (
< div
< div className = "avatar lg"
className = "avatar lg"
style = { { background : ` linear-gradient(145deg, oklch(0.62 0.15 ${ hue } ), oklch(0.48 0.16 ${ hue } )) ` , fontSize : 28 , fontWeight : 700 } } >
style = { {
background : ` linear-gradient(145deg, oklch(0.62 0.15 ${ hue } ), oklch(0.48 0.16 ${ hue } )) ` ,
fontSize : 28 ,
fontWeight : 700 ,
flexShrink : 0 ,
} }
>
{ clinic . name ? . [ 0 ] ? ? '?' }
{ clinic . name ? . [ 0 ] ? ? '?' }
< / div >
< / div >
) }
) }
< div >
< button onClick = { ( ) = > logoInputRef . current ? . click ( ) } disabled = { logoUploading }
< div style = { { fontWeight : 700 , fontSize : 18 } } > { clinic . name } < / div >
style = { {
< span className = { ` badge ${ clinic . is_active ? 'green' : 'gray' } ` } style = { { marginTop : 4 } } >
position : 'absolute' , insetInlineEnd : - 6 , bottom : - 6 , width : 24 , height : 24 ,
< span className = "bdot" / >
borderRadius : '50%' , background : 'var(--primary)' , border : '2px solid var(--surface)' ,
{ clinic . is_active ? 'فعال' : 'غیرفعال' }
display : 'flex' , alignItems : 'center' , justifyContent : 'center' , cursor : 'pointer' ,
} } >
< CameraIcon style = { { width : 12 , height : 12 , color : '#fff' } } / >
< / button >
< input ref = { logoInputRef } type = "file" accept = "image/*" style = { { display : 'none' } }
onChange = { e = > e . target . files ? . [ 0 ] && handleLogoUpload ( e . target . files [ 0 ] ) } / >
< / div >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontSize : 20 , fontWeight : 700 } } > { clinic . name } < / div >
< div style = { { display : 'flex' , gap : 6 , marginTop : 6 , flexWrap : 'wrap' } } >
< span className = { ` badge ${ clinic . is_active ? 'green' : 'gray' } ` } >
< span className = "bdot" / > { clinic . is_active ? 'فعال' : 'غیرفعال' }
< / span >
< / span >
{ clinic [ '24_7' ] && < span className = "badge amber" > < span className = "bdot" / > ۲ ۴ س ا ع ت ه < / span > }
< / div >
< / div >
< / div >
< / div >
< / div >
< div style = { { display : 'grid' , gridTemplateColumns : 'repeat(auto-fill, minmax(200px, 1fr))' , gap : 16 } } >
{ /* Info rows */ }
< InfoCard label = "تلفن" value = { clinic . phone ? < span dir = "ltr" > { clinic . phone } < / span > : '—' } / >
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
{ clinic . doctors_count !== undefined && (
< InfoTile icon = { < PhoneIcon style = { { width : 15 , height : 15 } } / > }
< InfoCard
label = "تلفن" value = { clinic . phone ? < span dir = "ltr" > { clinic . phone } < / span > : '—' } / >
label = "تعداد پزشکان"
< InfoTile icon = { < MapPinIcon style = { { width : 15 , height : 15 } } / > }
value = {
label = "شهر"
< span className = "badge blue" >
value = { [ cityName , provinceName ] . filter ( Boolean ) . join ( '، ' ) || '—' } / >
< span className = "bdot" / >
< InfoTile icon = { < BuildingOffice2Icon style = { { width : 15 , height : 15 } } / > }
{ clinic . doctors_count } پ ز ش ک
label = "آدرس" value = { clinic . location ? ? '—' } fullWidth / >
< / span >
< / div >
{ clinic . caption && (
< div style = { { marginTop : 16 , padding : '12px 14px' , background : 'var(--surface-2, var(--bg))' , borderRadius : 8 } } >
< div className = "muted" style = { { fontSize : 12 , marginBottom : 4 } } > ت و ض ی ح ا ت < / div >
< p style = { { fontSize : 13 , lineHeight : 1.7 } } > { clinic . caption } < / p >
< / div >
) }
< / div >
{ /* Doctors */ }
< div className = "card card-pad" >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : 14 } } >
< b style = { { fontSize : 14 } } > پ ز ش ک ا ن ( { formatNumber ( doctorList . length ) } ) < / b >
< / div >
{ doctorList . length === 0 ? (
< div className = "empty" style = { { padding : '20px 0' } } >
< p className = "muted" > ه ی چ پ ز ش ک ی ب ه ا ی ن ک ل ی ن ی ک م ت ص ل ن ی س ت < / p >
< / div >
) : (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 8 } } >
{ doctorList . map ( doc = > {
const dHue = HUES_LIST [ ( doc . uuid ? . charCodeAt ( 0 ) ? ? 0 ) % HUES_LIST . length ] ;
const img = doc . img ? . [ 0 ] ? . url ;
return (
< div key = { doc . id } style = { { display : 'flex' , alignItems : 'center' , gap : 10 , padding : '8px 10px' , borderRadius : 8 , background : 'var(--surface-2, var(--bg))' } } >
{ img
? < img src = { img } alt = "" className = "avatar sm" style = { { objectFit : 'cover' , flexShrink : 0 } } / >
: < div className = "avatar sm" style = { { background : ` linear-gradient(145deg, oklch(0.62 0.15 ${ dHue } ), oklch(0.48 0.16 ${ dHue } )) ` , flexShrink : 0 } } > { doc . name ? . [ 0 ] ? ? '?' } < / div >
}
}
/ >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontWeight : 600 , fontSize : 13 } } > { doc . name } < / div >
{ doc . specialties ? . length > 0 && (
< div className = "muted" style = { { fontSize : 11 } } > { doc . specialties . map ( s = > s . name ) . join ( '، ' ) } < / div >
) }
) }
{ clinic . created_at && (
< / div >
< InfoCard label = "تاریخ ثبت" value = { formatDate ( String ( clinic . created_at ) ) } / >
< span className = { ` badge ${ doc . active ? 'green' : 'gray' } ` } style = { { fontSize : 11 } } >
< span className = "bdot" / > { doc . active ? 'فعال' : 'غیرفعال' }
< / span >
< / div >
) ;
} ) }
< / div >
) }
) }
< / div >
< / div >
{ /* Gallery */ }
< div className = "card card-pad" >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : 14 } } >
< b style = { { fontSize : 14 } } > گ ا ل ر ی ت ص ا و ی ر < / b >
< button className = "btn ghost sm" onClick = { ( ) = > galleryInputRef . current ? . click ( ) } disabled = { galleryUploading } >
< PlusIcon style = { { width : 14 , height : 14 } } / >
{ galleryUploading ? 'در حال آپلود...' : 'افزودن تصویر' }
< / button >
< input ref = { galleryInputRef } type = "file" accept = "image/*" style = { { display : 'none' } }
onChange = { e = > e . target . files ? . [ 0 ] && handleGalleryUpload ( e . target . files [ 0 ] ) } / >
< / div >
{ ( clinic . images_clinic ? ? [ ] ) . length === 0 ? (
< div className = "empty" style = { { padding : '20px 0' } } >
< p className = "muted" > ه ی چ ت ص و ی ر ی د ر گ ا ل ر ی و ج و د ن د ا ر د < / p >
< / div >
) : (
< div style = { { display : 'grid' , gridTemplateColumns : 'repeat(auto-fill, minmax(110px, 1fr))' , gap : 8 } } >
{ clinic . images_clinic . map ( ( img , i ) = > (
< div key = { i } style = { { borderRadius : 8 , overflow : 'hidden' , aspectRatio : '1' , background : 'var(--surface-2, var(--bg))' } } >
< img src = { img . url } alt = "" style = { { width : '100%' , height : '100%' , objectFit : 'cover' } } / >
< / div >
) ) }
< / div >
) }
< / div >
{ /* Map (read-only) */ }
{ lat !== null && lng !== null && (
< div className = "card card-pad" >
< b style = { { fontSize : 14 , display : 'block' , marginBottom : 12 } } > م و ق ع ی ت ر و ی ن ق ش ه < / b >
< div style = { { borderRadius : 10 , overflow : 'hidden' , border : '1px solid var(--border)' , height : 260 } } >
< MapContainer center = { [ lat , lng ] } zoom = { 13 } style = { { height : '100%' , width : '100%' } } >
< TileLayer url = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution = '© <a href="https://openstreetmap.org">OpenStreetMap</a>' / >
< Marker position = { [ lat , lng ] } / >
< / MapContainer >
< / div >
< / div >
) }
< / div >
{ /* Right sidebar */ }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 'var(--gap)' } } >
{ /* Specialties */ }
< div className = "card card-pad" >
< b style = { { fontSize : 13 , display : 'block' , marginBottom : 10 } } >
ت خ ص ص ه ا ( { formatNumber ( ( clinic . specialties ? ? [ ] ) . length ) } )
< / b >
{ ( clinic . specialties ? ? [ ] ) . length === 0
? < p className = "muted" style = { { fontSize : 12 } } > ت خ ص ص ی ث ب ت ن ش د ه < / p >
: < div style = { { display : 'flex' , flexWrap : 'wrap' , gap : 6 } } >
{ clinic . specialties . map ( s = > (
< span key = { s . id } className = "badge violet" > < span className = "bdot" / > { s . name } < / span >
) ) }
< / div >
}
< / div >
{ /* Insurances */ }
< div className = "card card-pad" >
< b style = { { fontSize : 13 , display : 'block' , marginBottom : 10 } } >
ب ی م ه ه ا ( { formatNumber ( ( clinic . list_bime ? ? [ ] ) . length ) } )
< / b >
{ ( clinic . list_bime ? ? [ ] ) . length === 0
? < p className = "muted" style = { { fontSize : 12 } } > ب ی م ه ا ی ث ب ت ن ش د ه < / p >
: < div style = { { display : 'flex' , flexWrap : 'wrap' , gap : 6 } } >
{ clinic . list_bime . map ( ins = > (
< span key = { ins . id } className = "badge blue" > < span className = "bdot" / > { ins . name } < / span >
) ) }
< / div >
}
< / div >
{ /* Services */ }
< div className = "card card-pad" >
< b style = { { fontSize : 13 , display : 'block' , marginBottom : 10 } } >
خ د م ا ت ( { formatNumber ( ( clinic . services ? ? [ ] ) . length ) } )
< / b >
{ ( clinic . services ? ? [ ] ) . length === 0
? < p className = "muted" style = { { fontSize : 12 } } > خ د م ت ی ث ب ت ن ش د ه < / p >
: < div style = { { display : 'flex' , flexWrap : 'wrap' , gap : 6 } } >
{ clinic . services . map ( s = > (
< span key = { s . id } className = "badge green" > < span className = "bdot" / > { s . name } < / span >
) ) }
< / div >
}
< / div >
< / div >
< / div >
< / div >
{ /* Edit modal */ }
{ /* Edit modal */ }
{ editOpen && (
{ editOpen && (
< div className = "overlay" onClick = { ( ) = > setEditOpen ( false ) } >
< EditModal clinic = { clinic } onClose = { ( ) = > setEditOpen ( false ) }
< div className = "modal" style = { { maxWidth : 420 } } onClick = { ( e ) = > e . stopPropagation ( ) } >
onSaved = { ( ) = > { qc . invalidateQueries ( { queryKey : [ 'clinic-detail' , uuid ] } ) ; qc . invalidateQueries ( { queryKey : [ 'admin-clinics' ] } ) ; } } / >
< div className = "modal-head" >
< b > و ی ر ا ی ش ک ل ی ن ی ک < / b >
< button className = "mini-btn" onClick = { ( ) = > setEditOpen ( false ) } >
< XMarkIcon style = { { width : 16 , height : 16 } } / >
< / button >
< / div >
< div className = "modal-body" style = { { display : 'flex' , flexDirection : 'column' , gap : 14 } } >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , marginBottom : 6 , display : 'block' } } >
ن ا م ک ل ی ن ی ک
< / label >
< input
className = "input"
value = { editName }
onChange = { ( e ) = > setEditName ( e . target . value ) }
placeholder = "نام کلینیک"
/ >
< / div >
< div >
< label style = { { fontSize : 13 , fontWeight : 600 , marginBottom : 6 , display : 'block' } } >
ت ل ف ن
< / label >
< input
className = "input"
value = { editPhone }
onChange = { ( e ) = > setEditPhone ( e . target . value ) }
placeholder = "مثال: 021-12345678"
dir = "ltr"
/ >
< / div >
< / div >
< div className = "modal-foot" >
< button className = "btn ghost sm" onClick = { ( ) = > setEditOpen ( false ) } >
ا ن ص ر ا ف
< / button >
< button
className = "btn primary sm"
disabled = { updateMutation . isPending || ! editName . trim ( ) }
onClick = { ( ) = > updateMutation . mutate ( { name : editName.trim ( ) , phone : editPhone.trim ( ) || undefined } ) }
>
< CheckIcon style = { { width : 15 , height : 15 } } / >
{ updateMutation . isPending ? 'در حال ذخیره...' : 'ذخیره' }
< / button >
< / div >
< / div >
< / div >
) }
) }
{ /* Delete confirm */ }
< ConfirmDialog
open = { deleteOpen }
title = "حذف کلینیک"
message = { ` آیا از حذف کلینیک " ${ clinic . name } " اطمینان دارید؟ این عمل قابل بازگشت نیست. ` }
confirmLabel = "حذف"
danger
loading = { deleteMut . isPending }
onConfirm = { ( ) = > deleteMut . mutate ( ) }
onCancel = { ( ) = > setDeleteOpen ( false ) }
/ >
< / div >
< / div >
) ;
) ;
}
}
function InfoCard ( { label , value } : { label : string ; value : React.ReactNode } ) {
// ── Info tile ──────────────────────────────────────────────────────────────
function InfoTile ( { icon , label , value , fullWidth } : {
icon? : React.ReactNode ; label : string ; value : React.ReactNode ; fullWidth? : boolean ;
} ) {
return (
return (
< div style = { {
< div style = { {
gridColumn : fullWidth ? '1 / -1' : undefined ,
padding : '10px 12px' , borderRadius : 8 ,
background : 'var(--surface-2, var(--bg))' ,
background : 'var(--surface-2, var(--bg))' ,
border : '1px solid var(--border)' ,
border : '1px solid var(--border)' ,
borderRadius : 10 ,
padding : '12px 14px' ,
} } >
} } >
< div className = "muted" style = { { fontSize : 12 , marginBottom : 4 } } > { label } < / div >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 5 , marginBottom : 3 , color : 'var(--text-3)' } } >
< div style = { { fontWeight : 600 } } > { value } < / div >
{ icon }
< span style = { { fontSize : 11 } } > { label } < / span >
< / div >
< div style = { { fontSize : 13 , fontWeight : 600 } } > { value } < / div >
< / div >
< / div >
) ;
) ;
}
}