Files
clinicpro/assets/admin/pages/ClinicDetailPage.tsx
T
hamed 9ef94043c8 refactor: rename phone field to telephone in ClinicsPage and update related types
- Updated the form schema in ClinicsPage to change the field name from 'phone' to 'telephone'.
- Adjusted the input registration to reflect the new field name.
- Added a new interface ClinicDetail to define detailed clinic information including phone and other attributes.
- Modified the ClinicController to use null-safe access for province ID retrieval.
2026-06-10 21:49:16 +03:30

867 lines
41 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-dom';
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';
// 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];
// ── Types ──────────────────────────────────────────────────────────────────
interface ClinicDoctorItem {
id: string; uuid: string; name: string;
gender: string | null; degree: string | null;
img: { url: string }[];
specialties: { id: string; name: string }[];
active: boolean;
}
interface Opt { id: number; name: string; }
interface OptUuid { id: number; uuid: string; name: string; }
// ── Edit form schema ───────────────────────────────────────────────────────
const editSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
telephone: z.string().optional(),
info: z.string().optional(),
is_247: z.boolean(),
address: z.string().optional(),
province_id: z.number().nullable(),
city_id: z.number().nullable(),
latitude: z.string().optional(),
longitude: z.string().optional(),
specialties: z.array(z.number()),
insurance: z.array(z.number()),
doctor_services: z.array(z.number()),
});
type EditForm = z.infer<typeof editSchema>;
// ── Searchable Select (template CSS) ──────────────────────────────────────
function SearchableSelectField({ options, value, onChange, placeholder, disabled = false }: {
options: Opt[];
value: number | null;
onChange: (v: number | null, label?: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const [rect, setRect] = useState<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(
() => q ? options.filter(o => o.name.includes(q)) : options,
[options, q],
);
const selected = useMemo(() => options.find(o => o.id === value) ?? null, [options, value]);
const openDD = () => {
if (disabled || !btnRef.current) return;
setRect(btnRef.current.getBoundingClientRect());
setOpen(v => !v);
setQ('');
};
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node) &&
btnRef.current && !btnRef.current.contains(e.target as Node))
setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const dropStyle: React.CSSProperties = rect
? { position: 'fixed', top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999 }
: {};
return (
<div>
<button ref={btnRef} type="button" disabled={disabled} onClick={openDD}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '8px 12px', borderRadius: 8,
border: '1px solid var(--border)', background: disabled ? 'var(--surface-2, var(--bg))' : 'var(--surface)',
color: selected ? 'var(--text)' : 'var(--text-3)', cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 14, opacity: disabled ? 0.6 : 1,
}}>
<span>{selected?.name ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon style={{ width: 14, height: 14, flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
</button>
{open && createPortal(
<div ref={dropRef} style={{
...dropStyle,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,.12)', overflow: 'hidden',
}}>
<div style={{ padding: '8px 8px 0' }}>
<input autoFocus value={q} onChange={e => setQ(e.target.value)} placeholder="جستجو..."
className="input" style={{ fontSize: 13 }} />
</div>
<div style={{ maxHeight: 200, overflowY: 'auto', padding: '4px 0' }}>
<button type="button"
onClick={() => { onChange(null); setOpen(false); }}
style={{ width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13, color: 'var(--text-3)', background: 'none', border: 'none', cursor: 'pointer' }}>
{placeholder ?? 'انتخاب کنید'}
</button>
{filtered.map(o => (
<button key={o.id} type="button"
onClick={() => { onChange(o.id, o.name); setOpen(false); setQ(''); }}
style={{
width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13,
background: value === o.id ? 'var(--primary-light, oklch(0.95 0.04 256))' : 'none',
color: value === o.id ? 'var(--primary)' : 'var(--text)', border: 'none', cursor: 'pointer',
}}>
{o.name}
</button>
))}
{filtered.length === 0 && (
<p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجه‌ای یافت نشد</p>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
// ── Multi-select checkbox list ─────────────────────────────────────────────
function MultiCheckList({ options, selected, onChange, placeholder }: {
options: Opt[]; selected: number[]; onChange: (v: number[]) => void; placeholder?: string;
}) {
const [q, setQ] = useState('');
const filtered = useMemo(() => q ? options.filter(o => o.name.includes(q)) : options, [options, q]);
const toggle = (id: number) =>
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '6px 8px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2, var(--bg))' }}>
<input value={q} onChange={e => setQ(e.target.value)} placeholder={placeholder ?? 'جستجو...'}
className="input" style={{ fontSize: 13 }} />
</div>
{selected.length > 0 && (
<div style={{ padding: '6px 10px', borderBottom: '1px solid var(--border)', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{selected.map(id => {
const opt = options.find(o => o.id === id);
if (!opt) return null;
return (
<span key={id} className="badge violet" style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggle(id)}>
<span className="bdot" />{opt.name} ×
</span>
);
})}
</div>
)}
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
{filtered.length === 0
? <p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجه‌ای یافت نشد</p>
: filtered.map(o => (
<label key={o.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 12px', cursor: 'pointer' }}>
<input type="checkbox" checked={selected.includes(o.id)} onChange={() => toggle(o.id)} />
<span style={{ fontSize: 13, color: 'var(--text)' }}>{o.name}</span>
</label>
))}
</div>
</div>
);
}
// ── Leaflet helpers ────────────────────────────────────────────────────────
function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) });
return null;
}
function MapFlyController({ target }: { target: [number, number] | null }) {
const map = useMap();
useEffect(() => { if (target) map.flyTo(target, 12, { duration: 1.2 }); }, [target, map]);
return null;
}
function MapPicker({ lat, lng, onChange, flyTarget }: {
lat: number | null; lng: number | null;
onChange: (lat: number, lng: number) => void;
flyTarget?: [number, number] | null;
}) {
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
const center: [number, number] = pos ?? IRAN_CENTER;
return (
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border)', height: 260 }}>
<MapContainer center={center} zoom={pos ? 13 : 5} style={{ height: '100%', width: '100%' }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&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 [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<EditForm>({
resolver: zodResolver(editSchema),
defaultValues: {
name: clinic.name ?? '',
telephone: clinic.phone ?? clinic.phone_number ?? '',
info: clinic.caption ?? '',
is_247: clinic['24_7'] ?? false,
address: clinic.location ?? '',
province_id: clinic.state?.[0] ? Number(clinic.state[0].id) : null,
city_id: clinic.city?.[0] ? Number(clinic.city[0].id) : null,
latitude: clinic.map?.latitude ?? '',
longitude: clinic.map?.longitude ?? '',
specialties: (clinic.specialties ?? []).map(s => Number(s.id)),
insurance: (clinic.list_bime ?? []).map(s => Number(s.id)),
doctor_services: (clinic.services ?? []).map(s => Number(s.id)),
},
});
const provinceId = watch('province_id');
const cityId = watch('city_id');
const lat = watch('latitude');
const lng = watch('longitude');
const latN = lat ? parseFloat(lat) : null;
const lngN = lng ? parseFloat(lng) : null;
const watchedSpec = (watch('specialties') ?? []) as number[];
const watchedIns = (watch('insurance') ?? []) as number[];
const watchedSrv = (watch('doctor_services') ?? []) as number[];
const provincesQ = useQuery({
queryKey: ['provinces'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/provinces'),
});
const citiesQ = useQuery({
queryKey: ['cities', provinceId], staleTime: 300_000,
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/cities?province_id=${provinceId}`),
enabled: !!provinceId,
});
const specialtiesQ = useQuery({
queryKey: ['specialties-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/specialties'),
});
const insurancesQ = useQuery({
queryKey: ['insurances-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/insurances'),
});
const servicesQ = useQuery({
queryKey: ['doctor-services-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/doctor-services'),
});
const provinces: Opt[] = useMemo(() => (provincesQ.data?.data?.data ?? provincesQ.data?.data ?? []).map((p: any) => ({ id: Number(p.id ?? p.nid), name: p.name })), [provincesQ.data]);
const cities: Opt[] = useMemo(() => (citiesQ.data?.data?.data ?? citiesQ.data?.data ?? []).map((c: any) => ({ id: Number(c.id ?? c.nid), name: c.name })), [citiesQ.data]);
const specialties: Opt[] = useMemo(() => {
const raw = specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [];
return raw.map((s: any) => ({ id: Number(s.id), name: s.name }));
}, [specialtiesQ.data]);
const insurances: Opt[] = useMemo(() => {
const raw = insurancesQ.data?.data?.data ?? insurancesQ.data?.data ?? [];
return raw.map((i: any) => ({ id: Number(i.id), name: i.name }));
}, [insurancesQ.data]);
const services: Opt[] = useMemo(() => {
const raw = servicesQ.data?.data?.data ?? servicesQ.data?.data ?? [];
return raw.map((s: any) => ({ id: Number(s.id), name: s.name }));
}, [servicesQ.data]);
const saveMut = useMutation({
mutationFn: (values: EditForm) => api.patch<ApiResponse<any>>(`/api/v1/clinic/${clinic.uuid}`, {
name: values.name,
telephone: values.telephone || undefined,
info: values.info || undefined,
'24_7': values.is_247,
address: values.address || undefined,
state: values.province_id ? [values.province_id] : undefined,
city: values.city_id ? [values.city_id] : undefined,
latitude: values.latitude ? parseFloat(values.latitude) : undefined,
longitude: values.longitude ? parseFloat(values.longitude) : undefined,
specialties: values.specialties,
insurance: values.insurance,
doctor_services: values.doctor_services,
}),
onSuccess: () => { toast.success('اطلاعات کلینیک ذخیره شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>('basic');
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>ویرایش کلینیک</b>
<button className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
{/* Tab switcher */}
<div style={{ borderBottom: '1px solid var(--border)', padding: '0 16px', display: 'flex', gap: 4 }}>
{([['basic', 'اطلاعات پایه'], ['location', 'موقعیت'], ['tags', 'تخصص و بیمه']] as const).map(([id, label]) => (
<button key={id} type="button" onClick={() => setActiveTab(id)}
style={{
padding: '10px 14px', fontSize: 13, fontWeight: 600, border: 'none', background: 'none',
cursor: 'pointer', borderBottom: activeTab === id ? '2px solid var(--primary)' : '2px solid transparent',
color: activeTab === id ? 'var(--primary)' : 'var(--text-3)', marginBottom: -1,
}}>
{label}
</button>
))}
</div>
<form onSubmit={handleSubmit(v => saveMut.mutate(v))}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* ── Basic tab ── */}
{activeTab === 'basic' && (
<>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام کلینیک</label>
<input className="input" {...register('name')} />
{errors.name && <div className="err-text">{errors.name.message}</div>}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="021-12345678" {...register('telephone')} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>توضیحات</label>
<textarea className="input" rows={3} style={{ resize: 'vertical' }} {...register('info')} />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" {...register('is_247')} />
<span style={{ fontSize: 13, fontWeight: 600 }}>کلینیک ۲۴ ساعته (۷ روز هفته)</span>
</label>
</>
)}
{/* ── Location tab ── */}
{activeTab === 'location' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>استان</label>
<SearchableSelectField
options={provinces}
value={provinceId ?? null}
placeholder="انتخاب استان"
onChange={val => { setValue('province_id', val); setValue('city_id', null); }}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شهر</label>
<SearchableSelectField
options={cities}
value={cityId ?? null}
placeholder={provinceId ? 'انتخاب شهر' : 'ابتدا استان انتخاب کنید'}
disabled={!provinceId}
onChange={(val, label) => {
setValue('city_id', val);
if (label) geocodeCity(label).then(c => { if (c) setMapFlyTarget(c); });
}}
/>
</div>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>آدرس</label>
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>موقعیت روی نقشه</label>
{latN && lngN && (
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{latN.toFixed(4)}, {lngN.toFixed(4)}</span>
)}
</div>
<p className="muted" style={{ fontSize: 12, marginBottom: 6 }}>برای تعیین موقعیت روی نقشه کلیک کنید</p>
<MapPicker
lat={latN} lng={lngN} flyTarget={mapFlyTarget}
onChange={(lt, ln) => { setValue('latitude', String(lt)); setValue('longitude', String(ln)); }}
/>
{latN && lngN && (
<button type="button" className="btn ghost sm" style={{ marginTop: 6, fontSize: 12 }}
onClick={() => { setValue('latitude', ''); setValue('longitude', ''); }}>
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
</button>
)}
</div>
</>
)}
{/* ── Tags tab ── */}
{activeTab === 'tags' && (
<>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
تخصص‌ها ({watchedSpec.length} انتخاب‌شده)
</label>
<MultiCheckList options={specialties} selected={watchedSpec}
onChange={v => setValue('specialties', v)} placeholder="جستجوی تخصص..." />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
بیمه‌ها ({watchedIns.length} انتخاب‌شده)
</label>
<MultiCheckList options={insurances} selected={watchedIns}
onChange={v => setValue('insurance', v)} placeholder="جستجوی بیمه..." />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
خدمات ({watchedSrv.length} انتخاب‌شده)
</label>
<MultiCheckList options={services} selected={watchedSrv}
onChange={v => setValue('doctor_services', v)} placeholder="جستجوی خدمت..." />
</div>
</>
)}
</div>
<div className="modal-foot">
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
<button type="submit" className="btn primary sm" disabled={saveMut.isPending}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Main Page ──────────────────────────────────────────────────────────────
export default function ClinicDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
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 doctorsQ = useQuery({
queryKey: ['clinic-doctors', uuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${uuid}`),
enabled: !!uuid,
});
const clinic: ClinicDetail | undefined = useMemo(() => {
const raw = data?.data;
return (raw as any)?.data ?? raw;
}, [data]);
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const toggleMut = useMutation({
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
onSuccess: () => {
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 ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`,
},
body: file,
});
const json = await res.json();
const url = json?.data?.url;
if (url) {
await api.patch(`/api/v1/clinic/${uuid}`, { clinic_logo: url });
toast.success('لوگو آپلود شد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
}
} catch (e: any) { toast.error(e.message); }
finally { setLogoUploading(false); }
};
const handleGalleryUpload = async (file: File) => {
setGalleryUploading(true);
try {
const res = await fetch('/file/upload/clinic_pro/clinic/field_image_clinic', {
method: 'POST',
headers: {
'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`,
},
body: file,
});
const json = await res.json();
const url = json?.data?.url;
if (url && clinic) {
const existing = (clinic.images_clinic ?? []).map(img => img.url);
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, url] });
toast.success('تصویر اضافه شد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
}
} catch (e: any) { toast.error(e.message); }
finally { setGalleryUploading(false); }
};
const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const logo = clinic?.logo ?? clinic?.clinic_logo;
const lat = clinic?.map?.latitude ? parseFloat(clinic.map.latitude) : null;
const lng = clinic?.map?.longitude ? parseFloat(clinic.map.longitude) : null;
const cityName = clinic?.city?.[0]?.name;
const provinceName = clinic?.state?.[0]?.name;
if (isLoading) {
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 ── */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button className="btn ghost sm" onClick={() => navigate('/admin/clinics')} style={{ padding: '6px 10px' }}>
<ArrowRightIcon style={{ width: 16, height: 16 }} />
</button>
<div>
<h1 className="section-title">{clinic.name}</h1>
<div className="muted">جزئیات کلینیک</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn ghost sm" onClick={() => setEditOpen(true)}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
<button className={`btn sm ${clinic.is_active ? 'soft' : '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>
</div>
{/* ── Main grid ── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 'var(--gap)', alignItems: 'start' }}>
{/* Left column */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{/* Identity card */}
<div className="card card-pad">
<div 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>
)}
<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: '#fff' }} />
</button>
<input ref={logoInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleLogoUpload(e.target.files[0])} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{clinic.name}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />{clinic.is_active ? 'فعال' : 'غیرفعال'}
</span>
{clinic['24_7'] && <span className="badge amber"><span className="bdot" />۲۴ ساعته</span>}
</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={clinic.location ?? '—'} fullWidth />
</div>
{clinic.caption && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--surface-2, var(--bg))', borderRadius: 8 }}>
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>توضیحات</div>
<p style={{ fontSize: 13, lineHeight: 1.7 }}>{clinic.caption}</p>
</div>
)}
</div>
{/* Doctors */}
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<b style={{ fontSize: 14 }}>پزشکان ({formatNumber(doctorList.length)})</b>
</div>
{doctorList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{doctorList.map(doc => {
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const img = doc.img?.[0]?.url;
return (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
{img
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
{doc.specialties?.length > 0 && (
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
)}
</div>
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
</span>
</div>
);
})}
</div>
)}
</div>
{/* Gallery */}
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<b style={{ fontSize: 14 }}>گالری تصاویر</b>
<button className="btn ghost sm" onClick={() => galleryInputRef.current?.click()} disabled={galleryUploading}>
<PlusIcon style={{ width: 14, height: 14 }} />
{galleryUploading ? 'در حال آپلود...' : 'افزودن تصویر'}
</button>
<input ref={galleryInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleGalleryUpload(e.target.files[0])} />
</div>
{(clinic.images_clinic ?? []).length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ تصویری در گالری وجود ندارد</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}>
{clinic.images_clinic.map((img, i) => (
<div key={i} style={{ borderRadius: 8, overflow: 'hidden', aspectRatio: '1', background: 'var(--surface-2, var(--bg))' }}>
<img src={img.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
))}
</div>
)}
</div>
{/* Map (read-only) */}
{lat !== null && lng !== null && (
<div className="card card-pad">
<b style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>موقعیت روی نقشه</b>
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border)', height: 260 }}>
<MapContainer center={[lat, lng]} zoom={13} style={{ height: '100%', width: '100%' }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&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 */}
<div className="card card-pad">
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
تخصص‌ها ({formatNumber((clinic.specialties ?? []).length)})
</b>
{(clinic.specialties ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>تخصصی ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.specialties.map(s => (
<span key={s.id} className="badge violet"><span className="bdot" />{s.name}</span>
))}
</div>
}
</div>
{/* Insurances */}
<div className="card card-pad">
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
بیمه‌ها ({formatNumber((clinic.list_bime ?? []).length)})
</b>
{(clinic.list_bime ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>بیمه‌ای ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.list_bime.map(ins => (
<span key={ins.id} className="badge blue"><span className="bdot" />{ins.name}</span>
))}
</div>
}
</div>
{/* Services */}
<div className="card card-pad">
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
خدمات ({formatNumber((clinic.services ?? []).length)})
</b>
{(clinic.services ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>خدمتی ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.services.map(s => (
<span key={s.id} className="badge green"><span className="bdot" />{s.name}</span>
))}
</div>
}
</div>
</div>
</div>
{/* Edit modal */}
{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, var(--bg))',
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>
);
}