- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
1035 lines
48 KiB
TypeScript
1035 lines
48 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { useParams, useNavigate } from 'react-router';
|
||
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 {
|
||
ArrowRightIcon, PencilIcon, TrashIcon,
|
||
BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon,
|
||
PlusIcon, CameraIcon, ChevronDownIcon,
|
||
} 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 { api } from '../lib/api';
|
||
import type { ApiResponse } from '../lib/api';
|
||
import type { ClinicDetail } from '../types';
|
||
import { formatNumber } from '../lib/utils';
|
||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||
import Modal from '../components/ui/Modal';
|
||
import PageHeader from '../components/ui/PageHeader';
|
||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
|
||
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import { latinDigitsField } from '../lib/forms';
|
||
import Switch from '../components/ui/Switch';
|
||
|
||
// 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 IRAN_CENTER: [number, number] = [32.4279, 53.6880];
|
||
const MAX_GALLERY_IMAGES = 5;
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────────────
|
||
|
||
interface ClinicAddress {
|
||
id: string; uuid: string;
|
||
name: string | null; address: string | null;
|
||
telephone: string | null;
|
||
map: { latitude: string | null; longitude: string | null };
|
||
city: { id: string; name: string } | null;
|
||
province: { id: string; name: string } | null;
|
||
}
|
||
|
||
interface Opt { id: number; name: string; }
|
||
interface OptUuid { id: number; uuid: string; name: string; }
|
||
|
||
// ── Edit form schema ───────────────────────────────────────────────────────
|
||
|
||
const urlOrEmpty = z.string().refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'آدرس URL معتبر نیست' });
|
||
|
||
const editSchema = z.object({
|
||
name: z.string().min(1, 'نام الزامی است'),
|
||
telephone: z.string().optional(),
|
||
info: z.string().optional(),
|
||
is_247: z.boolean(),
|
||
specialties: z.array(z.number()),
|
||
insurance: z.array(z.number()),
|
||
doctor_services: z.array(z.number()),
|
||
sm_instagram: urlOrEmpty,
|
||
sm_telegram: urlOrEmpty,
|
||
sm_aparat: urlOrEmpty,
|
||
sm_youtube: urlOrEmpty,
|
||
sm_linkedin: urlOrEmpty,
|
||
});
|
||
type EditForm = z.infer<typeof editSchema>;
|
||
|
||
// ── 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)' }}>
|
||
<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 => (
|
||
<div key={o.id} style={{ padding: '7px 12px' }}>
|
||
<Switch inline checked={selected.includes(o.id)} onChange={() => toggle(o.id)} label={o.name} />
|
||
</div>
|
||
))}
|
||
</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, 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] = 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 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='© <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 { 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,
|
||
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)),
|
||
sm_instagram: clinic.social_media?.instagram ?? '',
|
||
sm_telegram: clinic.social_media?.telegram ?? '',
|
||
sm_aparat: clinic.social_media?.aparat ?? '',
|
||
sm_youtube: clinic.social_media?.youtube ?? '',
|
||
sm_linkedin: clinic.social_media?.linkedin ?? '',
|
||
},
|
||
});
|
||
|
||
const watchedSpec = (watch('specialties') ?? []) as number[];
|
||
const watchedIns = (watch('insurance') ?? []) as number[];
|
||
const watchedSrv = (watch('doctor_services') ?? []) as number[];
|
||
|
||
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 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,
|
||
specialties: values.specialties,
|
||
insurance: values.insurance,
|
||
doctor_services: values.doctor_services,
|
||
social_media: {
|
||
instagram: values.sm_instagram || null,
|
||
telegram: values.sm_telegram || null,
|
||
aparat: values.sm_aparat || null,
|
||
youtube: values.sm_youtube || null,
|
||
linkedin: values.sm_linkedin || null,
|
||
},
|
||
}),
|
||
onSuccess: () => { toast.success('اطلاعات کلینیک ذخیره شد'); onSaved(); onClose(); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
|
||
|
||
return createPortal(
|
||
<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', 'اطلاعات پایه'], ['tags', 'تخصص و بیمه'], ['social', 'شبکههای اجتماعی']] 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" placeholder="021-12345678" {...latinDigitsField(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>
|
||
<Switch
|
||
inline
|
||
checked={watch('is_247') ?? false}
|
||
onChange={(v) => setValue('is_247', v, { shouldDirty: true })}
|
||
label="کلینیک ۲۴ ساعته (۷ روز هفته)"
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Social tab ── */}
|
||
{activeTab === 'social' && (
|
||
<>
|
||
{([
|
||
['sm_instagram', 'اینستاگرام', 'https://instagram.com/...'],
|
||
['sm_telegram', 'تلگرام', 'https://t.me/...'],
|
||
['sm_aparat', 'آپارات', 'https://aparat.com/...'],
|
||
['sm_youtube', 'یوتیوب', 'https://youtube.com/...'],
|
||
['sm_linkedin', 'لینکدین', 'https://linkedin.com/...'],
|
||
] as const).map(([field, label, placeholder]) => (
|
||
<div key={field}>
|
||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>{label}</label>
|
||
<input className="input" dir="ltr" placeholder={placeholder} {...register(field)} />
|
||
{errors[field] && <div className="err-text">{errors[field]?.message}</div>}
|
||
</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>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
// ── Main Page ──────────────────────────────────────────────────────────────
|
||
|
||
export default function ClinicDetailPage() {
|
||
const { uuid } = useParams<{ uuid: string }>();
|
||
const navigate = useNavigate();
|
||
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 isReadOnly = primaryRole === 'representation';
|
||
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||
const [logoUploading, setLogoUploading] = useState(false);
|
||
const [galleryUploading, setGalleryUploading] = useState(false);
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['clinic-detail', uuid],
|
||
queryFn: () => api.get<ApiResponse<{ data: ClinicDetail }>>(`/api/v1/clinic/${uuid}`),
|
||
enabled: !!uuid,
|
||
});
|
||
|
||
const clinicAddressesQ = useQuery({
|
||
queryKey: ['clinic-addresses', uuid],
|
||
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
|
||
enabled: !!uuid,
|
||
});
|
||
const clinicAddresses: ClinicAddress[] = (clinicAddressesQ.data?.data as any)?.data ?? clinicAddressesQ.data?.data ?? [];
|
||
|
||
const [addrFormOpen, setAddrFormOpen] = useState(false);
|
||
const [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
|
||
const [deleteAddrConfirm, setDeleteAddrConfirm] = useState<ClinicAddress | null>(null);
|
||
const [deleteImageConfirm, setDeleteImageConfirm] = useState<string | null>(null);
|
||
|
||
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) => {
|
||
if (editingClinicAddr) {
|
||
return api.patch<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address/${editingClinicAddr.uuid}`, payload);
|
||
}
|
||
return api.post<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address`, payload);
|
||
},
|
||
onSuccess: () => {
|
||
toast.success(editingClinicAddr ? 'آدرس ویرایش شد' : 'آدرس اضافه شد');
|
||
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
|
||
setAddrFormOpen(false);
|
||
setEditingClinicAddr(null);
|
||
setAddrForm(emptyAddrForm);
|
||
},
|
||
onError: () => toast.error('خطا در ذخیره آدرس'),
|
||
});
|
||
|
||
const deleteAddrMutation = useMutation({
|
||
mutationFn: (addrUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/clinic/${uuid}/address/${addrUuid}`),
|
||
onSuccess: () => {
|
||
toast.success('آدرس حذف شد');
|
||
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
|
||
setDeleteAddrConfirm(null);
|
||
},
|
||
onError: (err: any) => toast.error(err?.message ?? 'خطا در حذف آدرس'),
|
||
});
|
||
|
||
const clinic: ClinicDetail | undefined = useMemo(() => {
|
||
const raw = data?.data;
|
||
return (raw as any)?.data ?? raw;
|
||
}, [data]);
|
||
|
||
const openEdit = () => setEditOpen(true);
|
||
|
||
const toggleMut = useMutation({
|
||
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
|
||
onSuccess: () => {
|
||
toast.success('وضعیت کلینیک تغییر کرد');
|
||
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
|
||
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const deleteMut = useMutation({
|
||
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/${uuid}`),
|
||
onSuccess: () => { toast.success('کلینیک حذف شد'); navigate('/admin/clinics'); },
|
||
onError: (e: Error) => toast.error(e.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 ${authToken ?? ''}`,
|
||
},
|
||
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 (files: FileList) => {
|
||
const existing = (clinic?.images_clinic ?? []).filter(img => img?.url);
|
||
const remaining = MAX_GALLERY_IMAGES - existing.length;
|
||
if (remaining <= 0) {
|
||
toast.error(`گالری حداکثر ${MAX_GALLERY_IMAGES} عکس میتواند داشته باشد`);
|
||
return;
|
||
}
|
||
const selected = Array.from(files);
|
||
if (selected.length > remaining) {
|
||
toast.error(`فقط ${remaining} عکس دیگر میتوانید اضافه کنید (حداکثر ${MAX_GALLERY_IMAGES})`);
|
||
}
|
||
const toUpload = selected.slice(0, remaining);
|
||
setGalleryUploading(true);
|
||
try {
|
||
const uploaded: { url: string }[] = [];
|
||
for (const file of toUpload) {
|
||
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) {
|
||
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); }
|
||
finally { setGalleryUploading(false); }
|
||
};
|
||
|
||
const handleGalleryRemove = async (url: string) => {
|
||
if (!clinic) return;
|
||
try {
|
||
const remaining = (clinic.images_clinic ?? []).filter(img => img?.url && img.url !== url);
|
||
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: remaining });
|
||
toast.success('تصویر حذف شد');
|
||
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
|
||
} catch (e: any) { toast.error(e?.message ?? 'خطا در حذف تصویر'); }
|
||
};
|
||
|
||
const clinicName = clinic?.name ?? 'کلینیک';
|
||
const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||
const logo = clinic?.logo ?? clinic?.clinic_logo;
|
||
// شهر و استان از آدرس 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 (
|
||
<div className="fade-in">
|
||
<div className="card card-pad">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="skeleton" style={{ height: 20, borderRadius: 6, marginBottom: 12 }} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!clinic) {
|
||
return (
|
||
<div className="fade-in">
|
||
<div className="card card-pad">
|
||
<div className="empty">
|
||
<BuildingOffice2Icon style={{ width: 36, height: 36 }} />
|
||
<p>کلینیک یافت نشد</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="fade-in">
|
||
|
||
{/* ── Header ── */}
|
||
<PageHeader
|
||
backTo="/admin/clinics"
|
||
title={clinicName}
|
||
breadcrumbs={[{ label: 'کلینیکها', to: '/admin/clinics' }, { label: clinicName }]}
|
||
action={
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||
{!isReadOnly && (
|
||
<button className="btn soft sm" onClick={() => openEdit()}>
|
||
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
|
||
</button>
|
||
)}
|
||
{primaryRole === 'admin' && (
|
||
<>
|
||
<button className={`btn sm ${clinic.is_active ? 'ghost' : 'primary'}`}
|
||
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
|
||
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||
</button>
|
||
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
|
||
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* ── Main grid ── */}
|
||
<div className="split-2">
|
||
|
||
{/* Left column */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||
|
||
{/* Identity card */}
|
||
<div className="card card-pad">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||
{logo ? (
|
||
<img src={logo} alt="" className="avatar lg" style={{ objectFit: 'cover' }} />
|
||
) : (
|
||
<div className="avatar lg"
|
||
style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`, fontSize: 28, fontWeight: 700 }}>
|
||
{clinic.name?.[0] ?? '?'}
|
||
</div>
|
||
)}
|
||
{!isReadOnly && (
|
||
<>
|
||
<button onClick={() => logoInputRef.current?.click()} disabled={logoUploading}
|
||
style={{
|
||
position: 'absolute', insetInlineEnd: -6, bottom: -6, width: 24, height: 24,
|
||
borderRadius: '50%', background: 'var(--primary)', border: '2px solid var(--surface)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||
}}>
|
||
<CameraIcon style={{ width: 12, height: 12, color: 'var(--on-primary)' }} />
|
||
</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 }}>
|
||
{/* نام در PageHeader آمده — اینجا وضعیت و شهر، نه تکرار عنوان */}
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`}>
|
||
<span className="bdot" />{clinic.is_active ? 'فعال' : 'غیرفعال'}
|
||
</span>
|
||
{clinic['24_7'] && <span className="badge amber"><span className="bdot" />۲۴ ساعته</span>}
|
||
</div>
|
||
{(cityName || provinceName) && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 8, color: 'var(--text-3)', fontSize: 12.5 }}>
|
||
<MapPinIcon style={{ width: 14, height: 14, flexShrink: 0 }} />
|
||
{[cityName, provinceName].filter(Boolean).join('، ')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info rows */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<InfoTile icon={<PhoneIcon style={{ width: 15, height: 15 }} />}
|
||
label="تلفن" value={clinic.phone ? <span dir="ltr">{clinic.phone}</span> : '—'} />
|
||
<InfoTile icon={<MapPinIcon style={{ width: 15, height: 15 }} />}
|
||
label="شهر"
|
||
value={[cityName, provinceName].filter(Boolean).join('، ') || '—'} />
|
||
<InfoTile icon={<BuildingOffice2Icon style={{ width: 15, height: 15 }} />}
|
||
label="آدرس" value={primaryAddr?.address ?? clinic.location ?? '—'} fullWidth />
|
||
</div>
|
||
|
||
{clinic.caption && (
|
||
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--surface-2)', borderRadius: 8 }}>
|
||
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>توضیحات</div>
|
||
<p style={{ fontSize: 13, lineHeight: 1.7 }}>{clinic.caption}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Doctors + Invitations — shared manager */}
|
||
<ClinicDoctorsManager clinicUuid={uuid!} readOnly={isReadOnly} />
|
||
|
||
{/* Gallery */}
|
||
<div className="card card-pad">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||
<b style={{ fontSize: 14 }}>
|
||
گالری تصاویر ({formatNumber((clinic.images_clinic ?? []).filter(i => i?.url).length)}/{formatNumber(MAX_GALLERY_IMAGES)})
|
||
</b>
|
||
{!isReadOnly && (
|
||
<>
|
||
<button className="btn ghost sm" onClick={() => galleryInputRef.current?.click()}
|
||
disabled={galleryUploading || (clinic.images_clinic ?? []).filter(i => i?.url).length >= MAX_GALLERY_IMAGES}>
|
||
<PlusIcon style={{ width: 14, height: 14 }} />
|
||
{galleryUploading ? 'در حال آپلود...' : 'افزودن تصویر'}
|
||
</button>
|
||
<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' }}>
|
||
<p className="muted">هیچ تصویری در گالری وجود ندارد</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}>
|
||
{clinic.images_clinic.filter(img => img?.url).map((img, i) => (
|
||
<div key={i} style={{ position: 'relative', borderRadius: 8, overflow: 'hidden', aspectRatio: '1', background: 'var(--surface-2)' }}>
|
||
<img src={img.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||
{!isReadOnly && (
|
||
<button
|
||
type="button"
|
||
className="mini-btn danger"
|
||
title="حذف تصویر"
|
||
onClick={() => setDeleteImageConfirm(img.url)}
|
||
style={{ position: 'absolute', top: 6, left: 6 }}
|
||
>
|
||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||
</button>
|
||
)}
|
||
</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 + Insurances + Services combined */}
|
||
<div className="card card-pad">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||
<b style={{ fontSize: 13 }}>تخصصها، بیمه و خدمات</b>
|
||
{!isReadOnly && (
|
||
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit()}>
|
||
<PencilIcon style={{ width: 13, height: 13 }} /> ویرایش
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div>
|
||
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
||
تخصصها ({formatNumber((clinic.specialties ?? []).length)})
|
||
</div>
|
||
{(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>
|
||
|
||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
||
بیمهها ({formatNumber((clinic.list_bime ?? []).length)})
|
||
</div>
|
||
{(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>
|
||
|
||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
||
خدمات ({formatNumber((clinic.services ?? []).length)})
|
||
</div>
|
||
{(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>
|
||
|
||
{primaryRole === 'clinic' && (
|
||
<NotificationMobileCard target="clinic" />
|
||
)}
|
||
|
||
{/* Clinic Addresses Section */}
|
||
{(isOwner || primaryRole === 'admin' || isReadOnly) && (
|
||
<div className="card">
|
||
<div className="toolbar" style={{ padding: '12px 16px' }}>
|
||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||
آدرسهای کلینیک ({formatNumber(clinicAddresses.length)})
|
||
</div>
|
||
{(isOwner || primaryRole === 'admin') && clinicAddresses.length === 0 && (
|
||
<button className="btn primary sm" onClick={() => openAddrForm(null)}>
|
||
<PlusIcon style={{ width: 14, height: 14 }} />
|
||
افزودن آدرس
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{clinicAddresses.length === 0 ? (
|
||
<div className="empty" style={{ padding: '24px 16px' }}>
|
||
<MapPinIcon style={{ width: 28, height: 28 }} />
|
||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||
هنوز آدرسی ثبت نشده — دکتران نمیتوانند این کلینیک را به عنوان لوکیشن انتخاب کنند
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||
{clinicAddresses.map((addr, idx) => (
|
||
<div key={addr.uuid} style={{
|
||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||
padding: '12px 16px',
|
||
borderTop: idx === 0 ? '1px solid var(--border)' : undefined,
|
||
borderBottom: '1px solid var(--border)',
|
||
}}>
|
||
<MapPinIcon style={{ width: 18, height: 18, color: 'var(--primary)', flexShrink: 0, marginTop: 2 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{addr.name && <div style={{ fontWeight: 600, fontSize: 13 }}>{addr.name}</div>}
|
||
{addr.address && <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{addr.address}</div>}
|
||
{addr.telephone && (
|
||
<div style={{ fontSize: 12, marginTop: 2, color: 'var(--text-2)' }}>
|
||
<PhoneIcon style={{ width: 12, height: 12, display: 'inline', marginLeft: 4 }} />
|
||
{addr.telephone}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(isOwner || primaryRole === 'admin') && (
|
||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||
<button className="mini-btn" title="ویرایش آدرس" onClick={() => openAddrForm(addr)}>
|
||
<PencilIcon style={{ width: 14, height: 14 }} />
|
||
</button>
|
||
<button className="mini-btn danger" title="حذف آدرس"
|
||
onClick={() => setDeleteAddrConfirm(addr)}>
|
||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Clinic Address Form Modal */}
|
||
<Modal
|
||
open={addrFormOpen}
|
||
title={editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}
|
||
size="md"
|
||
onClose={() => setAddrFormOpen(false)}
|
||
footer={
|
||
<>
|
||
<button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button>
|
||
<button className="btn primary" disabled={saveAddrMutation.isPending}
|
||
onClick={() => saveAddrMutation.mutate(addrForm)}>
|
||
{saveAddrMutation.isPending ? 'در حال ذخیره…' : 'ذخیره'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div className="field-block">
|
||
<label>نام شعبه / عنوان</label>
|
||
<div className="field">
|
||
<input placeholder="مثال: شعبه مرکزی"
|
||
value={addrForm.name}
|
||
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 14 }}>
|
||
<div className="field-block">
|
||
<label>استان</label>
|
||
<SearchableSelect
|
||
options={provinces.map(p => ({ value: p.id, label: p.name }))}
|
||
value={addrForm.province_id}
|
||
placeholder="انتخاب استان"
|
||
isClearable
|
||
isLoading={provincesQ.isLoading}
|
||
onChange={val => setAddrForm(f => ({
|
||
...f, province_id: val === null ? null : Number(val), city_id: null,
|
||
}))}
|
||
/>
|
||
</div>
|
||
<div className="field-block">
|
||
<label>شهر</label>
|
||
<SearchableSelect
|
||
options={addrCities.map(c => ({ value: c.id, label: c.name }))}
|
||
value={addrForm.city_id}
|
||
placeholder={addrForm.province_id ? 'انتخاب شهر' : 'ابتدا استان را انتخاب کنید'}
|
||
isDisabled={!addrForm.province_id}
|
||
isClearable
|
||
isLoading={citiesQ.isLoading}
|
||
onChange={val => {
|
||
const id = val === null ? null : Number(val);
|
||
setAddrForm(f => ({ ...f, city_id: id }));
|
||
// نقشه روی شهر انتخابی میپرد تا کاربر از وسط ایران شروع نکند.
|
||
const label = addrCities.find(c => c.id === id)?.name;
|
||
if (label) geocodeCity(label).then(c => { if (c) setAddrMapFlyTarget(c); });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field-block">
|
||
<label>آدرس کامل</label>
|
||
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک…"
|
||
value={addrForm.address}
|
||
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
|
||
</div>
|
||
|
||
<div className="field-block">
|
||
<label>تلفن</label>
|
||
<div className="field">
|
||
<input dir="ltr" placeholder="مثال: 02112345678"
|
||
value={addrForm.telephone}
|
||
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field-block">
|
||
<label style={{ justifyContent: 'space-between' }}>
|
||
<span>موقعیت روی نقشه</span>
|
||
{addrForm.latitude && addrForm.longitude && (
|
||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-3)', direction: 'ltr' }}>
|
||
{addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
|
||
</span>
|
||
)}
|
||
</label>
|
||
<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 }))}
|
||
/>
|
||
<span className="field-hint">برای تعیین موقعیت دقیق، روی نقشه کلیک کنید</span>
|
||
{addrForm.latitude && addrForm.longitude && (
|
||
<button type="button" className="btn ghost sm" style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||
onClick={() => setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
|
||
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* Delete Address Confirm */}
|
||
<ConfirmDialog
|
||
open={deleteAddrConfirm !== null}
|
||
title="حذف آدرس"
|
||
message={`آیا از حذف آدرس "${deleteAddrConfirm?.name ?? deleteAddrConfirm?.address ?? ''}" اطمینان دارید؟`}
|
||
confirmLabel="حذف"
|
||
onConfirm={() => deleteAddrConfirm && deleteAddrMutation.mutate(deleteAddrConfirm.uuid)}
|
||
onCancel={() => setDeleteAddrConfirm(null)}
|
||
/>
|
||
|
||
{/* Delete Gallery Image Confirm */}
|
||
<ConfirmDialog
|
||
open={deleteImageConfirm !== null}
|
||
title="حذف تصویر"
|
||
message="آیا از حذف این تصویر از گالری اطمینان دارید؟"
|
||
confirmLabel="حذف"
|
||
danger
|
||
onConfirm={() => {
|
||
if (deleteImageConfirm) handleGalleryRemove(deleteImageConfirm);
|
||
setDeleteImageConfirm(null);
|
||
}}
|
||
onCancel={() => setDeleteImageConfirm(null)}
|
||
/>
|
||
|
||
{/* Edit modal — self-portals to escape transform context */}
|
||
{editOpen && (
|
||
<EditModal
|
||
clinic={clinic}
|
||
onClose={() => setEditOpen(false)}
|
||
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
|
||
/>
|
||
)}
|
||
|
||
{/* Delete confirm */}
|
||
<ConfirmDialog
|
||
open={deleteOpen}
|
||
title="حذف کلینیک"
|
||
message={`آیا از حذف کلینیک "${clinic.name}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||
confirmLabel="حذف"
|
||
danger
|
||
loading={deleteMut.isPending}
|
||
onConfirm={() => deleteMut.mutate()}
|
||
onCancel={() => setDeleteOpen(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Info tile ──────────────────────────────────────────────────────────────
|
||
|
||
function InfoTile({ icon, label, value, fullWidth }: {
|
||
icon?: React.ReactNode; label: string; value: React.ReactNode; fullWidth?: boolean;
|
||
}) {
|
||
return (
|
||
<div style={{
|
||
gridColumn: fullWidth ? '1 / -1' : undefined,
|
||
padding: '10px 12px', borderRadius: 8,
|
||
background: 'var(--surface-2)',
|
||
border: '1px solid var(--border)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 3, color: 'var(--text-3)' }}>
|
||
{icon}
|
||
<span style={{ fontSize: 11 }}>{label}</span>
|
||
</div>
|
||
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|