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(), telephone: z.string().optional(),
info: z.string().optional(), info: z.string().optional(),
is_247: z.boolean(), 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()), specialties: z.array(z.number()),
insurance: z.array(z.number()), insurance: z.array(z.number()),
doctor_services: 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]); useEffect(() => { if (target) map.flyTo(target, 12, { duration: 1.2 }); }, [target, map]);
return null; return null;
} }
function MapPicker({ lat, lng, onChange, flyTarget }: { function MapPicker({ lat, lng, onChange, flyTarget, initialCenter }: {
lat: number | null; lng: number | null; lat: number | null; lng: number | null;
onChange: (lat: number, lng: number) => void; onChange: (lat: number, lng: number) => void;
flyTarget?: [number, number] | null; flyTarget?: [number, number] | null;
initialCenter?: [number, number] | null;
}) { }) {
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : 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 ( return (
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border)', height: 260 }}> <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" <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>' /> attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>' />
<MapClickHandler onPick={onChange} /> <MapClickHandler onPick={onChange} />
@@ -271,12 +268,9 @@ async function geocodeCity(name: string): Promise<[number, number] | null> {
// ── Edit Modal ───────────────────────────────────────────────────────────── // ── Edit Modal ─────────────────────────────────────────────────────────────
function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: { function EditModal({ clinic, onClose, onSaved }: {
clinic: ClinicDetail; onClose: () => void; onSaved: () => void; 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>({ const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<EditForm>({
resolver: zodResolver(editSchema), resolver: zodResolver(editSchema),
defaultValues: { defaultValues: {
@@ -284,36 +278,16 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
telephone: clinic.phone ?? clinic.phone_number ?? '', telephone: clinic.phone ?? clinic.phone_number ?? '',
info: clinic.caption ?? '', info: clinic.caption ?? '',
is_247: clinic['24_7'] ?? false, 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)), specialties: (clinic.specialties ?? []).map(s => Number(s.id)),
insurance: (clinic.list_bime ?? []).map(s => Number(s.id)), insurance: (clinic.list_bime ?? []).map(s => Number(s.id)),
doctor_services: (clinic.services ?? []).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 watchedSpec = (watch('specialties') ?? []) as number[];
const watchedIns = (watch('insurance') ?? []) as number[]; const watchedIns = (watch('insurance') ?? []) as number[];
const watchedSrv = (watch('doctor_services') ?? []) 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({ const specialtiesQ = useQuery({
queryKey: ['specialties-all'], staleTime: 600_000, queryKey: ['specialties-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/specialties'), 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'), 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 specialties: Opt[] = useMemo(() => {
const raw = specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? []; const raw = specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [];
return raw.map((s: any) => ({ id: Number(s.id), name: s.name })); 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, telephone: values.telephone || undefined,
info: values.info || undefined, info: values.info || undefined,
'24_7': values.is_247, '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, specialties: values.specialties,
insurance: values.insurance, insurance: values.insurance,
doctor_services: values.doctor_services, doctor_services: values.doctor_services,
@@ -361,7 +328,7 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>(initialTab); const [activeTab, setActiveTab] = useState<'basic' | 'tags'>('basic');
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
@@ -375,7 +342,7 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
{/* Tab switcher */} {/* Tab switcher */}
<div style={{ borderBottom: '1px solid var(--border)', padding: '0 16px', display: 'flex', gap: 4 }}> <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)} <button key={id} type="button" onClick={() => setActiveTab(id)}
style={{ style={{
padding: '10px 14px', fontSize: 13, fontWeight: 600, border: 'none', background: 'none', 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 ── */} {/* ── Tags tab ── */}
{activeTab === 'tags' && ( {activeTab === 'tags' && (
<> <>
@@ -525,10 +439,10 @@ export default function ClinicDetailPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole); const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid); const dbUuid = useAuthStore(s => s.dbUuid);
const authToken = useAuthStore(s => s.token);
const isOwner = primaryRole === 'clinic' && dbUuid === uuid; const isOwner = primaryRole === 'clinic' && dbUuid === uuid;
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors'); 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 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 [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
const [deleteAddrConfirm, setDeleteAddrConfirm] = 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({ const saveAddrMutation = useMutation({
mutationFn: (payload: typeof addrForm) => { mutationFn: (payload: typeof addrForm) => {
@@ -580,7 +522,7 @@ export default function ClinicDetailPage() {
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] }); qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
setAddrFormOpen(false); setAddrFormOpen(false);
setEditingClinicAddr(null); setEditingClinicAddr(null);
setAddrForm({ name: '', address: '', telephone: '' }); setAddrForm(emptyAddrForm);
}, },
onError: () => toast.error('خطا در ذخیره آدرس'), onError: () => toast.error('خطا در ذخیره آدرس'),
}); });
@@ -607,10 +549,7 @@ export default function ClinicDetailPage() {
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? []; const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const openEdit = (tab: 'basic' | 'location' | 'tags' = 'basic') => { const openEdit = () => setEditOpen(true);
setEditInitialTab(tab);
setEditOpen(true);
};
const toggleMut = useMutation({ const toggleMut = useMutation({
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}), mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
@@ -655,7 +594,7 @@ export default function ClinicDetailPage() {
headers: { headers: {
'Content-Disposition': `filename="${file.name}"`, 'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream', 'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`, Authorization: `Bearer ${authToken ?? ''}`,
}, },
body: file, body: file,
}); });
@@ -670,24 +609,27 @@ export default function ClinicDetailPage() {
finally { setLogoUploading(false); } finally { setLogoUploading(false); }
}; };
const handleGalleryUpload = async (file: File) => { const handleGalleryUpload = async (files: FileList) => {
setGalleryUploading(true); setGalleryUploading(true);
try { try {
const res = await fetch('/file/upload/clinic_pro/clinic/field_image_clinic', { const uploaded: { url: string }[] = [];
method: 'POST', for (const file of Array.from(files)) {
headers: { const res = await fetch('/file/upload/clinic_pro/clinic/field_image_clinic', {
'Content-Disposition': `filename="${file.name}"`, method: 'POST',
'Content-Type': file.type || 'application/octet-stream', headers: {
Authorization: `Bearer ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`, 'Content-Disposition': `filename="${file.name}"`,
}, 'Content-Type': file.type || 'application/octet-stream',
body: file, Authorization: `Bearer ${authToken ?? ''}`,
}); },
const json = await res.json(); body: file,
const url = json?.data?.url; });
if (url && clinic) { 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); const existing = (clinic.images_clinic ?? []).filter(img => img?.url);
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, { url }] }); await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, ...uploaded] });
toast.success('تصویر اضافه شد'); toast.success(`${uploaded.length} تصویر اضافه شد`);
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
} }
} catch (e: any) { toast.error(e.message); } } 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 hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const logo = clinic?.logo ?? clinic?.clinic_logo; const logo = clinic?.logo ?? clinic?.clinic_logo;
const lat = clinic?.map?.latitude ? parseFloat(clinic.map.latitude) : null; // شهر و استان از آدرس DoctorAddress (منبع واقعی) نه از Clinic entity
const lng = clinic?.map?.longitude ? parseFloat(clinic.map.longitude) : null; const primaryAddr = clinicAddresses[0] ?? null;
const cityName = clinic?.city?.[0]?.name; const cityName = primaryAddr?.city?.name ?? clinic?.city?.[0]?.name;
const provinceName = clinic?.state?.[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) { if (isLoading) {
return ( return (
@@ -741,7 +687,7 @@ export default function ClinicDetailPage() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <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 }} /> ویرایش <PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button> </button>
{primaryRole === 'admin' && ( {primaryRole === 'admin' && (
@@ -806,7 +752,7 @@ export default function ClinicDetailPage() {
label="شهر" label="شهر"
value={[cityName, provinceName].filter(Boolean).join('، ') || '—'} /> value={[cityName, provinceName].filter(Boolean).join('، ') || '—'} />
<InfoTile icon={<BuildingOffice2Icon style={{ width: 15, height: 15 }} />} <InfoTile icon={<BuildingOffice2Icon style={{ width: 15, height: 15 }} />}
label="آدرس" value={clinic.location ?? '—'} fullWidth /> label="آدرس" value={primaryAddr?.address ?? clinic.location ?? '—'} fullWidth />
</div> </div>
{clinic.caption && ( {clinic.caption && (
@@ -944,8 +890,8 @@ export default function ClinicDetailPage() {
<PlusIcon style={{ width: 14, height: 14 }} /> <PlusIcon style={{ width: 14, height: 14 }} />
{galleryUploading ? 'در حال آپلود...' : 'افزودن تصویر'} {galleryUploading ? 'در حال آپلود...' : 'افزودن تصویر'}
</button> </button>
<input ref={galleryInputRef} type="file" accept="image/*" style={{ display: 'none' }} <input ref={galleryInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleGalleryUpload(e.target.files[0])} /> onChange={e => e.target.files?.length && handleGalleryUpload(e.target.files)} />
</div> </div>
{(clinic.images_clinic ?? []).length === 0 ? ( {(clinic.images_clinic ?? []).length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}> <div className="empty" style={{ padding: '20px 0' }}>
@@ -984,7 +930,7 @@ export default function ClinicDetailPage() {
<div className="card card-pad"> <div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<b style={{ fontSize: 13 }}>تخصصها، بیمه و خدمات</b> <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 }} /> ویرایش <PencilIcon style={{ width: 13, height: 13 }} /> ویرایش
</button> </button>
</div> </div>
@@ -1045,12 +991,8 @@ export default function ClinicDetailPage() {
<div style={{ fontWeight: 600, fontSize: 14 }}> <div style={{ fontWeight: 600, fontSize: 14 }}>
آدرسهای کلینیک ({formatNumber(clinicAddresses.length)}) آدرسهای کلینیک ({formatNumber(clinicAddresses.length)})
</div> </div>
{isOwner && ( {(isOwner || primaryRole === 'admin') && clinicAddresses.length === 0 && (
<button className="btn primary sm" onClick={() => { <button className="btn primary sm" onClick={() => openAddrForm(null)}>
setEditingClinicAddr(null);
setAddrForm({ name: '', address: '', telephone: '' });
setAddrFormOpen(true);
}}>
<PlusIcon style={{ width: 14, height: 14 }} /> <PlusIcon style={{ width: 14, height: 14 }} />
افزودن آدرس افزودن آدرس
</button> </button>
@@ -1084,13 +1026,9 @@ export default function ClinicDetailPage() {
</div> </div>
)} )}
</div> </div>
{isOwner && ( {(isOwner || primaryRole === 'admin') && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => { <button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => openAddrForm(addr)}>
setEditingClinicAddr(addr);
setAddrForm({ name: addr.name ?? '', address: addr.address ?? '', telephone: addr.telephone ?? '' });
setAddrFormOpen(true);
}}>
<PencilIcon style={{ width: 14, height: 14 }} /> <PencilIcon style={{ width: 14, height: 14 }} />
</button> </button>
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }} <button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }}
@@ -1110,50 +1048,92 @@ export default function ClinicDetailPage() {
</div> </div>
{/* Clinic Address Form Modal */} {/* Clinic Address Form Modal */}
{addrFormOpen && ( {addrFormOpen && createPortal(
<div className="modal-backdrop" onClick={() => setAddrFormOpen(false)}> <div className="overlay" onClick={() => setAddrFormOpen(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-head">
<span>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</span> <b>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</b>
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}> <button className="mini-btn" onClick={() => setAddrFormOpen(false)}>
<XMarkIcon style={{ width: 18, height: 18 }} /> <XMarkIcon style={{ width: 16, height: 16 }} />
</button> </button>
</div> </div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
<label className="field-label">نام شعبه / عنوان</label> <label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام شعبه / عنوان</label>
<div className="field"> <input className="input" placeholder="مثال: شعبه مرکزی"
<input type="text" placeholder="مثال: شعبه مرکزی" value={addrForm.name}
value={addrForm.name} onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
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> </div>
<div> <div>
<label className="field-label">آدرس</label> <label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>آدرس کامل</label>
<div className="field"> <textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک..."
<input type="text" placeholder="آدرس کامل" value={addrForm.address}
value={addrForm.address} onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
</div>
</div> </div>
<div> <div>
<label className="field-label">تلفن</label> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<div className="field"> <label style={{ fontSize: 13, fontWeight: 600 }}>موقعیت روی نقشه</label>
<input type="text" placeholder="مثال: 02112345678" {addrForm.latitude && addrForm.longitude && (
value={addrForm.telephone} <span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} /> {addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
</span>
)}
</div> </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> </div>
<div className="modal-footer"> <div className="modal-foot">
<button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button> <button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>انصراف</button>
<button className="btn primary" disabled={saveAddrMutation.isPending} <button className="btn primary sm" disabled={saveAddrMutation.isPending}
onClick={() => saveAddrMutation.mutate(addrForm)}> onClick={() => saveAddrMutation.mutate(addrForm)}>
{saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'} {saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>,
document.body,
)} )}
{/* Delete Address Confirm */} {/* Delete Address Confirm */}
@@ -1170,7 +1150,6 @@ export default function ClinicDetailPage() {
{editOpen && createPortal( {editOpen && createPortal(
<EditModal <EditModal
clinic={clinic} clinic={clinic}
initialTab={editInitialTab}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} 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 HUES_LIST = [256, 205, 162, 295, 272];
const addSchema = z.object({ const addSchema = z.object({
owner_mobile: z.string().min(10, 'شماره موبایل معتبر نیست'),
name: z.string().min(2, 'نام الزامی است'), name: z.string().min(2, 'نام الزامی است'),
telephone: z.string().optional(), telephone: z.string().optional(),
}); });
@@ -69,7 +70,7 @@ export default function ClinicsPage() {
const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) }); const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) });
const addMutation = useMutation({ 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) => { onSuccess: (res) => {
toast.success('کلینیک اضافه شد'); toast.success('کلینیک اضافه شد');
setAddOpen(false); setAddOpen(false);
@@ -237,7 +238,16 @@ export default function ClinicsPage() {
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}> <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> </label>
<input className="input" placeholder="نام کلینیک را وارد کنید" {...addForm.register('name')} /> <input className="input" placeholder="نام کلینیک را وارد کنید" {...addForm.register('name')} />
{addForm.formState.errors.name && ( {addForm.formState.errors.name && (
@@ -504,6 +504,12 @@ class AdminApiController extends BaseController
$this->em->persist($user); $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 = new Clinic($user);
$clinic->setName($name); $clinic->setName($name);
if (!empty($data['telephone'])) $clinic->setTelephone($data['telephone']); 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); 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) ?? []; $data = json_decode($request->getContent(), true) ?? [];
$address = DoctorAddress::forClinic($clinic->getId()); $address = DoctorAddress::forClinic($clinic->getId());
$this->hydrateClinicAddress($address, $data); $this->hydrateClinicAddress($address, $data);
@@ -618,11 +622,6 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); 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); $this->addressRepo->remove($address);
return $this->success(['message' => 'آدرس با موفقیت حذف شد']); return $this->success(['message' => 'آدرس با موفقیت حذف شد']);