Files
clinicpro/assets/admin/pages/ClinicDetailPage.tsx
T
hamed e88ae9bf9c feat: enhance ClinicDetailPage with dynamic tab management in EditModal
- Added initialTab prop to EditModal for setting the active tab on open.
- Updated state management in ClinicDetailPage to handle initial tab for editing.
- Refactored openEdit function to set the initial tab before opening the edit modal.
- Combined specialties, insurances, and services sections in the sidebar for better organization.
- Improved modal rendering using createPortal for better context handling.

style: increase z-index for modal overlay

- Updated the z-index of the overlay class in styles.css to ensure modals appear above other elements.

feat: implement multi-role dashboard functionality

- Created a new prompt for multi-role dashboard implementation.
- Defined roles and their access levels in the admin panel.
- Updated backend to support user role identification and context retrieval.
- Enhanced frontend to dynamically render components based on user roles.
- Added new routes and components for role-specific dashboards.

chore: add skills for admin endpoint and page creation

- Created SKILL.md files for adding admin endpoints and pages.
- Provided templates and guidelines for implementing new admin features.

chore: sync database after entity changes

- Added a new skill for syncing the database after any entity modifications.
2026-06-11 09:28:51 +03:30

1093 lines
53 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,
EnvelopeIcon, ArrowPathIcon, NoSymbolIcon,
} 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, PaginatedResponse } 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 ClinicInvitation {
uuid: string;
mobile: string;
invited_name: string | null;
invited_specialty: string | null;
status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed';
token_used: boolean;
invited_at: number;
expires_at: number;
responded_at: number | null;
doctor: { uuid: string; name: string } | null;
}
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, initialTab = 'basic' }: {
clinic: ClinicDetail; onClose: () => void; onSaved: () => void;
initialTab?: 'basic' | 'location' | 'tags';
}) {
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<EditForm>({
resolver: zodResolver(editSchema),
defaultValues: {
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'>(initialTab);
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>
);
}
// ── Invitation status badge ────────────────────────────────────────────────
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'amber' },
accepted: { label: 'پذیرفته‌شده', cls: 'green' },
rejected: { label: 'رد شده', cls: 'gray' },
suspended: { label: 'تعلیق', cls: 'violet' },
removed: { label: 'حذف‌شده', cls: 'gray' },
};
// ── Invite modal ───────────────────────────────────────────────────────────
const inviteSchema = z.object({
mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع می‌شود'),
name: z.string().optional(),
specialty: z.string().optional(),
});
type InviteForm = z.infer<typeof inviteSchema>;
function InviteModal({ clinicUuid, onClose, onInvited }: {
clinicUuid: string; onClose: () => void; onInvited: () => void;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
resolver: zodResolver(inviteSchema),
});
const inviteMut = useMutation({
mutationFn: (d: InviteForm) =>
api.post<ApiResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
onSuccess: () => { toast.success('دعوتنامه ارسال شد'); onInvited(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>دعوت پزشک به کلینیک</b>
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
</div>
<form onSubmit={handleSubmit(d => inviteMut.mutate(d))}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
<input className="input" placeholder="مثال: قلب و عروق" {...register('specialty')} />
</div>
<p className="muted" style={{ fontSize: 12 }}>پیامک دعوتنامه با لینک ۷۲ ساعته ارسال می‌شود</p>
</div>
<div className="modal-foot">
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
<button type="submit" className="btn primary sm" disabled={inviteMut.isPending}>
{inviteMut.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 [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
const [deleteOpen, setDeleteOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
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 invitationsQ = useQuery({
queryKey: ['clinic-invitations', uuid],
queryFn: () => api.get<PaginatedResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${uuid}/invitations?limit=50`),
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 invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const openEdit = (tab: 'basic' | 'location' | 'tags' = 'basic') => {
setEditInitialTab(tab);
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 resendInvMut = useMutation({
mutationFn: (invUuid: string) => api.post<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}),
onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const changeInvStatusMut = useMutation({
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
onSuccess: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const deleteInvMut = useMutation({
mutationFn: (invUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/invitation/${invUuid}`),
onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
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 ?? []).filter(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={() => openEdit('basic')}>
<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 + Invitations card */}
<div className="card card-pad">
{/* Card header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="seg">
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
پزشکان ({formatNumber(doctorList.length)})
</button>
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
دعوتنامه‌ها ({formatNumber(invitationList.length)})
</button>
</div>
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
</button>
</div>
{/* Doctors tab */}
{doctorsTab === 'doctors' && (
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>
)
)}
{/* Invitations tab */}
{doctorsTab === 'invitations' && (
invitationList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<EnvelopeIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ دعوتنامه‌ای ارسال نشده</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{invitationList.map(inv => {
const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' };
const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at;
return (
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{inv.invited_name ?? inv.mobile}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{inv.mobile}</span>
{inv.invited_specialty && (
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
)}
{inv.doctor && (
<span className="badge blue" style={{ fontSize: 11 }}><span className="bdot" />{inv.doctor.name}</span>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
</span>
<div style={{ display: 'flex', gap: 4 }}>
{inv.status === 'pending' && (
<button
className="mini-btn"
title="ارسال مجدد"
disabled={resendInvMut.isPending}
onClick={() => resendInvMut.mutate(inv.uuid)}
>
<ArrowPathIcon style={{ width: 13, height: 13 }} />
</button>
)}
{inv.status !== 'removed' && inv.status !== 'accepted' && (
<button
className="mini-btn"
title="تعلیق"
disabled={changeInvStatusMut.isPending}
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
>
<NoSymbolIcon style={{ width: 13, height: 13 }} />
</button>
)}
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
</div>
</div>
</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.filter(img => img?.url).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 + Insurances + Services combined */}
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<b style={{ fontSize: 13 }}>تخصص‌ها، بیمه و خدمات</b>
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit('tags')}>
<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>
</div>
</div>
{/* Edit modal — portal to escape Leaflet transform context */}
{editOpen && createPortal(
<EditModal
clinic={clinic}
initialTab={editInitialTab}
onClose={() => setEditOpen(false)}
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
/>,
document.body,
)}
{/* Invite doctor modal */}
{inviteOpen && uuid && createPortal(
<InviteModal
clinicUuid={uuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
/>,
document.body,
)}
{/* 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>
);
}