feat: update ClinicDetailPage and ClinicsPage for address handling and UI improvements

This commit is contained in:
hamed
2026-06-12 22:39:11 +03:30
parent 960ff1ab29
commit 87966a90fa
5 changed files with 235 additions and 174 deletions
+67
View File
@@ -0,0 +1,67 @@
# ClinicDetailPage — پنج اصلاح
در `assets/admin/pages/ClinicDetailPage.tsx` پنج مشکل وجود دارد که باید همه را درست کنی:
---
## ۱. شهر اشتباه نمایش داده می‌شود
در خطوط حدود ۶۳۲–۶۳۳، `cityName` و `provinceName` از `clinic.city[0]` و `clinic.state[0]`
(روی Clinic entity) خوانده می‌شوند. منبع درست، `clinicAddresses[0].city` و
`clinicAddresses[0].province` است (جدول `doctor_addresses`).
- ترجیحاً از `clinicAddresses[0]` بخواند و اگر خالی بود fallback به Clinic entity داشته باشد
- `InfoTile` آدرس هم باید از `clinicAddresses[0].address` بخواند نه `clinic.location`
---
## ۲. هر کلینیک فقط یک آدرس
**فرانت‌اند**: دکمه «افزودن آدرس» وقتی `clinicAddresses.length >= 1` است مخفی شود؛
فقط دکمه «ویرایش» نمایش داده شود.
**بک‌اند** (`src/Clinic/Controller/ClinicController.php`، متد `createAddress`):
قبل از ساخت آدرس جدید بررسی کند آیا این کلینیک قبلاً آدرس دارد. اگر داشت 409 برگرداند.
همچنین محدودیت حذف (`remaining <= 1`) را بردار — دیگر نیازی نیست چون کاربر می‌تواند ویرایش کند.
---
## ۳. مودال آدرس — استان، شهر و نقشه
فرم افزودن/ویرایش آدرس (مودال `addrFormOpen`) باید استان، شهر و نقشه داشته باشد:
- `addrForm` state باید `province_id`, `city_id`, `latitude`, `longitude` هم داشته باشد
- هنگام باز کردن مودال برای ویرایش، مقادیر `addr.province`, `addr.city`, `addr.map` لود شوند
- در مودال، ابتدا استان انتخاب می‌شود، سپس شهرهای آن استان لود می‌شوند
- **وقتی شهر انتخاب می‌شود**: با `geocodeCity` (همان تابعی که در EditModal قبلاً بود)좌표 شهر گرفته شود و نقشه با `flyTo` روی آن زوم کند — دقیقاً همان رفتار تب location قبلی
- زیر dropdown شهر، `MapPicker` نمایش داده شود تا کاربر بتواند موقعیت دقیق را روی نقشه انتخاب کند
- در `saveAddrMutation` مقادیر `province_id`, `city_id`, `latitude`, `longitude` به payload اضافه شوند
- بک‌اند (`hydrateClinicAddress`) قبلاً همه این فیلدها را handle می‌کند — نیازی به تغییر ندارد
---
## ۴. لوگو آپلود نمی‌شود
در `handleLogoUpload` و `handleGalleryUpload` توکن اینطور خوانده می‌شود:
```typescript
JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''
```
این روش ممکن است fail کند. باید از `useAuthStore` استفاده شود:
```typescript
const token = useAuthStore(s => s.token);
```
و در fetch header از این `token` استفاده شود.
---
## ۵. گالری فقط یک تصویر می‌گیرد
`<input ref={galleryInputRef} type="file" accept="image/*">` بدون `multiple` است.
- `multiple` به input اضافه شود
- `handleGalleryUpload` تغییر کند تا روی همه فایل‌های انتخاب‌شده (`e.target.files`) loop بزند
و همه را یکی‌یکی آپلود کند
+146 -167
View File
@@ -77,11 +77,6 @@ const editSchema = z.object({
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()),
@@ -237,16 +232,18 @@ function MapFlyController({ target }: { target: [number, number] | null }) {
useEffect(() => { if (target) map.flyTo(target, 12, { duration: 1.2 }); }, [target, map]);
return null;
}
function MapPicker({ lat, lng, onChange, flyTarget }: {
function MapPicker({ lat, lng, onChange, flyTarget, initialCenter }: {
lat: number | null; lng: number | null;
onChange: (lat: number, lng: number) => void;
flyTarget?: [number, number] | null;
initialCenter?: [number, number] | null;
}) {
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
const center: [number, number] = pos ?? IRAN_CENTER;
const center: [number, number] = initialCenter ?? pos ?? IRAN_CENTER;
const zoom = initialCenter ?? pos ? 13 : 5;
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%' }}>
<MapContainer key={`${center[0]},${center[1]}`} center={center} zoom={zoom} style={{ height: '100%', width: '100%' }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>' />
<MapClickHandler onPick={onChange} />
@@ -271,12 +268,9 @@ async function geocodeCity(name: string): Promise<[number, number] | null> {
// ── Edit Modal ─────────────────────────────────────────────────────────────
function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
function EditModal({ clinic, onClose, onSaved }: {
clinic: ClinicDetail; onClose: () => void; onSaved: () => void;
initialTab?: 'basic' | 'location' | 'tags';
}) {
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<EditForm>({
resolver: zodResolver(editSchema),
defaultValues: {
@@ -284,36 +278,16 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
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'),
@@ -327,8 +301,6 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
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 }));
@@ -348,11 +320,6 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
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,
@@ -361,7 +328,7 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
onError: (e: Error) => toast.error(e.message),
});
const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>(initialTab);
const [activeTab, setActiveTab] = useState<'basic' | 'tags'>('basic');
return (
<div className="overlay" onClick={onClose}>
@@ -375,7 +342,7 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
{/* Tab switcher */}
<div style={{ borderBottom: '1px solid var(--border)', padding: '0 16px', display: 'flex', gap: 4 }}>
{([['basic', 'اطلاعات پایه'], ['location', 'موقعیت'], ['tags', 'تخصص و بیمه']] as const).map(([id, label]) => (
{([['basic', 'اطلاعات پایه'], ['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',
@@ -413,59 +380,6 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
</>
)}
{/* ── 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' && (
<>
@@ -525,10 +439,10 @@ export default function ClinicDetailPage() {
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
const authToken = useAuthStore(s => s.token);
const isOwner = primaryRole === 'clinic' && dbUuid === uuid;
const [editOpen, setEditOpen] = useState(false);
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
const [deleteOpen, setDeleteOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
@@ -562,11 +476,39 @@ export default function ClinicDetailPage() {
});
const clinicAddresses: ClinicAddress[] = (clinicAddressesQ.data?.data as any)?.data ?? clinicAddressesQ.data?.data ?? [];
const [addrFormOpen, setAddrFormOpen] = useState(false);
const [addrFormOpen, setAddrFormOpen] = useState(false);
const [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
const [deleteAddrConfirm, setDeleteAddrConfirm] = useState<ClinicAddress | null>(null);
const [addrForm, setAddrForm] = useState({ name: '', address: '', telephone: '' });
const emptyAddrForm = { name: '', address: '', telephone: '', province_id: null as number | null, city_id: null as number | null, latitude: null as number | null, longitude: null as number | null };
const [addrForm, setAddrForm] = useState(emptyAddrForm);
const [addrMapFlyTarget, setAddrMapFlyTarget] = useState<[number, number] | null>(null);
const provincesQ = useQuery({
queryKey: ['provinces'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/provinces'),
});
const citiesQ = useQuery({
queryKey: ['cities', addrForm.province_id], staleTime: 300_000,
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/cities?province_id=${addrForm.province_id}`),
enabled: !!addrForm.province_id,
});
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 addrCities: Opt[] = useMemo(() => (citiesQ.data?.data?.data ?? citiesQ.data?.data ?? []).map((c: any) => ({ id: Number(c.id ?? c.nid), name: c.name })), [citiesQ.data]);
const openAddrForm = (addr: ClinicAddress | null) => {
setEditingClinicAddr(addr);
const lat = addr?.map?.latitude ? parseFloat(addr.map.latitude) : null;
const lng = addr?.map?.longitude ? parseFloat(addr.map.longitude) : null;
setAddrForm(addr ? {
name: addr.name ?? '', address: addr.address ?? '', telephone: addr.telephone ?? '',
province_id: addr.province ? Number(addr.province.id) : null,
city_id: addr.city ? Number(addr.city.id) : null,
latitude: lat, longitude: lng,
} : emptyAddrForm);
setAddrMapFlyTarget(lat && lng ? [lat, lng] : null);
setAddrFormOpen(true);
};
const saveAddrMutation = useMutation({
mutationFn: (payload: typeof addrForm) => {
@@ -580,7 +522,7 @@ export default function ClinicDetailPage() {
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
setAddrFormOpen(false);
setEditingClinicAddr(null);
setAddrForm({ name: '', address: '', telephone: '' });
setAddrForm(emptyAddrForm);
},
onError: () => toast.error('خطا در ذخیره آدرس'),
});
@@ -607,10 +549,7 @@ export default function ClinicDetailPage() {
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const openEdit = (tab: 'basic' | 'location' | 'tags' = 'basic') => {
setEditInitialTab(tab);
setEditOpen(true);
};
const openEdit = () => setEditOpen(true);
const toggleMut = useMutation({
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
@@ -655,7 +594,7 @@ export default function ClinicDetailPage() {
headers: {
'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`,
Authorization: `Bearer ${authToken ?? ''}`,
},
body: file,
});
@@ -670,24 +609,27 @@ export default function ClinicDetailPage() {
finally { setLogoUploading(false); }
};
const handleGalleryUpload = async (file: File) => {
const handleGalleryUpload = async (files: FileList) => {
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 uploaded: { url: string }[] = [];
for (const file of Array.from(files)) {
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 ${authToken ?? ''}`,
},
body: file,
});
const json = await res.json();
if (json?.data?.url) uploaded.push({ url: json.data.url });
}
if (uploaded.length > 0 && clinic) {
const existing = (clinic.images_clinic ?? []).filter(img => img?.url);
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, { url }] });
toast.success('تصویر اضافه شد');
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, ...uploaded] });
toast.success(`${uploaded.length} تصویر اضافه شد`);
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
}
} catch (e: any) { toast.error(e.message); }
@@ -696,10 +638,14 @@ export default function ClinicDetailPage() {
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;
// شهر و استان از آدرس DoctorAddress (منبع واقعی) نه از Clinic entity
const primaryAddr = clinicAddresses[0] ?? null;
const cityName = primaryAddr?.city?.name ?? clinic?.city?.[0]?.name;
const provinceName = primaryAddr?.province?.name ?? clinic?.state?.[0]?.name;
const lat = primaryAddr?.map?.latitude ? parseFloat(primaryAddr.map.latitude)
: clinic?.map?.latitude ? parseFloat(clinic.map.latitude) : null;
const lng = primaryAddr?.map?.longitude ? parseFloat(primaryAddr.map.longitude)
: clinic?.map?.longitude ? parseFloat(clinic.map.longitude) : null;
if (isLoading) {
return (
@@ -741,7 +687,7 @@ export default function ClinicDetailPage() {
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn ghost sm" onClick={() => openEdit('basic')}>
<button className="btn ghost sm" onClick={() => openEdit()}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
{primaryRole === 'admin' && (
@@ -806,7 +752,7 @@ export default function ClinicDetailPage() {
label="شهر"
value={[cityName, provinceName].filter(Boolean).join('، ') || '—'} />
<InfoTile icon={<BuildingOffice2Icon style={{ width: 15, height: 15 }} />}
label="آدرس" value={clinic.location ?? '—'} fullWidth />
label="آدرس" value={primaryAddr?.address ?? clinic.location ?? '—'} fullWidth />
</div>
{clinic.caption && (
@@ -944,8 +890,8 @@ export default function ClinicDetailPage() {
<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])} />
<input ref={galleryInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => e.target.files?.length && handleGalleryUpload(e.target.files)} />
</div>
{(clinic.images_clinic ?? []).length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
@@ -984,7 +930,7 @@ export default function ClinicDetailPage() {
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<b style={{ fontSize: 13 }}>تخصصها، بیمه و خدمات</b>
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit('tags')}>
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit()}>
<PencilIcon style={{ width: 13, height: 13 }} /> ویرایش
</button>
</div>
@@ -1045,12 +991,8 @@ export default function ClinicDetailPage() {
<div style={{ fontWeight: 600, fontSize: 14 }}>
آدرسهای کلینیک ({formatNumber(clinicAddresses.length)})
</div>
{isOwner && (
<button className="btn primary sm" onClick={() => {
setEditingClinicAddr(null);
setAddrForm({ name: '', address: '', telephone: '' });
setAddrFormOpen(true);
}}>
{(isOwner || primaryRole === 'admin') && clinicAddresses.length === 0 && (
<button className="btn primary sm" onClick={() => openAddrForm(null)}>
<PlusIcon style={{ width: 14, height: 14 }} />
افزودن آدرس
</button>
@@ -1084,13 +1026,9 @@ export default function ClinicDetailPage() {
</div>
)}
</div>
{isOwner && (
{(isOwner || primaryRole === 'admin') && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => {
setEditingClinicAddr(addr);
setAddrForm({ name: addr.name ?? '', address: addr.address ?? '', telephone: addr.telephone ?? '' });
setAddrFormOpen(true);
}}>
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => openAddrForm(addr)}>
<PencilIcon style={{ width: 14, height: 14 }} />
</button>
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }}
@@ -1110,50 +1048,92 @@ export default function ClinicDetailPage() {
</div>
{/* Clinic Address Form Modal */}
{addrFormOpen && (
<div className="modal-backdrop" onClick={() => setAddrFormOpen(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<span>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</span>
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>
<XMarkIcon style={{ width: 18, height: 18 }} />
{addrFormOpen && createPortal(
<div className="overlay" onClick={() => setAddrFormOpen(false)}>
<div className="modal" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</b>
<button className="mini-btn" onClick={() => setAddrFormOpen(false)}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className="field-label">نام شعبه / عنوان</label>
<div className="field">
<input type="text" placeholder="مثال: شعبه مرکزی"
value={addrForm.name}
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام شعبه / عنوان</label>
<input className="input" placeholder="مثال: شعبه مرکزی"
value={addrForm.name}
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
</div>
<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={addrForm.province_id}
placeholder="انتخاب استان"
onChange={val => setAddrForm(f => ({ ...f, province_id: val, city_id: null }))}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شهر</label>
<SearchableSelectField
options={addrCities}
value={addrForm.city_id}
placeholder={addrForm.province_id ? 'انتخاب شهر' : 'ابتدا استان'}
disabled={!addrForm.province_id}
onChange={(val, label) => {
setAddrForm(f => ({ ...f, city_id: val }));
if (label) geocodeCity(label).then(c => { if (c) setAddrMapFlyTarget(c); });
}}
/>
</div>
</div>
<div>
<label className="field-label">آدرس</label>
<div className="field">
<input type="text" placeholder="آدرس کامل"
value={addrForm.address}
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
</div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>آدرس کامل</label>
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک..."
value={addrForm.address}
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
</div>
<div>
<label className="field-label">تلفن</label>
<div className="field">
<input type="text" placeholder="مثال: 02112345678"
value={addrForm.telephone}
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>موقعیت روی نقشه</label>
{addrForm.latitude && addrForm.longitude && (
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>
{addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
</span>
)}
</div>
<p className="muted" style={{ fontSize: 12, marginBottom: 6 }}>برای تعیین موقعیت دقیق روی نقشه کلیک کنید</p>
<MapPicker
lat={addrForm.latitude} lng={addrForm.longitude}
flyTarget={addrMapFlyTarget}
initialCenter={addrForm.latitude && addrForm.longitude ? [addrForm.latitude, addrForm.longitude] : null}
onChange={(lt, ln) => setAddrForm(f => ({ ...f, latitude: lt, longitude: ln }))}
/>
{addrForm.latitude && addrForm.longitude && (
<button type="button" className="btn ghost sm" style={{ marginTop: 6, fontSize: 12 }}
onClick={() => setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
</button>
)}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="مثال: 02112345678"
value={addrForm.telephone}
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
</div>
</div>
<div className="modal-footer">
<button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button>
<button className="btn primary" disabled={saveAddrMutation.isPending}
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>انصراف</button>
<button className="btn primary sm" disabled={saveAddrMutation.isPending}
onClick={() => saveAddrMutation.mutate(addrForm)}>
{saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</div>
</div>,
document.body,
)}
{/* Delete Address Confirm */}
@@ -1170,7 +1150,6 @@ export default function ClinicDetailPage() {
{editOpen && createPortal(
<EditModal
clinic={clinic}
initialTab={editInitialTab}
onClose={() => setEditOpen(false)}
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
/>,
+12 -2
View File
@@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
const HUES_LIST = [256, 205, 162, 295, 272];
const addSchema = z.object({
owner_mobile: z.string().min(10, 'شماره موبایل معتبر نیست'),
name: z.string().min(2, 'نام الزامی است'),
telephone: z.string().optional(),
});
@@ -69,7 +70,7 @@ export default function ClinicsPage() {
const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) });
const addMutation = useMutation({
mutationFn: (d: AddForm) => api.post<ApiResponse<{ uuid: string }>>('/api/v1/clinic', d),
mutationFn: (d: AddForm) => api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/clinic', d),
onSuccess: (res) => {
toast.success('کلینیک اضافه شد');
setAddOpen(false);
@@ -237,7 +238,16 @@ export default function ClinicsPage() {
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
نام کلینیک
شماره موبایل صاحب کلینیک <span style={{ color: 'var(--red)' }}>*</span>
</label>
<input className="input" placeholder="09xxxxxxxxx" dir="ltr" {...addForm.register('owner_mobile')} />
{addForm.formState.errors.owner_mobile && (
<div className="err-text">{addForm.formState.errors.owner_mobile.message}</div>
)}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
نام کلینیک <span style={{ color: 'var(--red)' }}>*</span>
</label>
<input className="input" placeholder="نام کلینیک را وارد کنید" {...addForm.register('name')} />
{addForm.formState.errors.name && (
@@ -504,6 +504,12 @@ class AdminApiController extends BaseController
$this->em->persist($user);
}
$roles = $user->getRoles();
if (!in_array('ROLE_CLINIC', $roles, true)) {
$roles[] = 'ROLE_CLINIC';
$user->setRoles(array_values(array_unique($roles)));
}
$clinic = new Clinic($user);
$clinic->setName($name);
if (!empty($data['telephone'])) $clinic->setTelephone($data['telephone']);
+4 -5
View File
@@ -567,6 +567,10 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
if ($this->addressRepo->countByClinic($clinic->getId()) > 0) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کلینیک قبلاً آدرس دارد. برای ویرایش از endpoint PATCH استفاده کنید', 409);
}
$data = json_decode($request->getContent(), true) ?? [];
$address = DoctorAddress::forClinic($clinic->getId());
$this->hydrateClinicAddress($address, $data);
@@ -618,11 +622,6 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
}
$remaining = $this->addressRepo->countByClinic($clinic->getId());
if ($remaining <= 1) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'کلینیک باید حداقل یک آدرس داشته باشد', 409);
}
$this->addressRepo->remove($address);
return $this->success(['message' => 'آدرس با موفقیت حذف شد']);