Files
clinicpro/assets/admin/pages/ClinicDetailPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- 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.
2026-08-07 21:13:38 +03:30

1035 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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='&copy; <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='&copy; <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>
);
}