Files
clinicpro/assets/admin/pages/DoctorDetailPage.tsx
T
hamed e0e8fbd1e4 feat: implement BackButton component for consistent navigation
- Added BackButton component to standardize back navigation across pages.
- Integrated BackButton into various pages, replacing custom back buttons for consistency.
- Updated PageHeader to accept backTo prop for displaying BackButton when navigating from subpages.
- Created useGoBack hook to handle navigation logic, determining whether to go back in history or redirect to a fallback page.
- Added tests for BackButton and its integration with PageHeader to ensure expected behavior.
2026-07-29 20:26:51 +03:30

1900 lines
92 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, useCallback } from 'react';
import { avatarGradient } from '../lib/avatarColors';
import { createPortal } from 'react-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ArrowRightIcon, PencilIcon, TrashIcon,
XCircleIcon, PhoneIcon, CalendarIcon,
ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon,
BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon,
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon,
HeartIcon, CheckIcon, IdentificationIcon, DocumentTextIcon, GlobeAltIcon,
LockClosedIcon,
} from '@heroicons/react/24/outline';
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { api, ApiError } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import type { ApiResponse } from '../lib/api';
import { formatNumber, iranMobileOptionalSchema, toDate, toGregorianDate, displayDoctorName } from '../lib/utils';
import MobileInput from '../components/ui/MobileInput';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import GlobalSearchableSelect from '../components/ui/SearchableSelect';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import ImageCropModal from '../components/ImageCropModal';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import { latinDigitsField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
// Fix leaflet default marker 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',
});
// ── Types ──────────────────────────────────────────────────────────────────
interface DoctorDetail {
id: string; uuid: string; name: string; gender: string | null;
experience: number; activity_time: string | null; medical_system_code: string | null;
detail: string | null; degree: string | null; active: boolean;
specialties: { id: string; uuid: string; name: string; parent_id: string | null }[];
img: { url: string; fid: number }[];
expertise: { id: string; uuid: string; name: string }[];
satisfaction: string; point: string;
social_media: {
instagram: string | null; telegram: string | null; aparat: string | null;
youtube: string | null; linkedin: string | null;
} | null;
address: AddressData[];
state: { id: string; name: string }[];
city: { id: string; name: string }[];
clinics: { id: string; uuid: string; name: string; address: string | null; telephone: string | null }[];
representation: { id: number; uuid: string; full_name: string | null } | null;
}
interface SpecialtyOpt { id: number; uuid: string; name: string; parent_id: number | null; }
interface ServiceOpt { id: number; uuid: string; name: string; specialty_id: number | null; }
interface ProvinceOpt { id: number; uuid: string; name: string; }
interface CityOpt { id: number; uuid: string; name: string; }
interface ImageFileData { fid: number; uuid: string; url: string; filename: string; filemime: string; filesize: number; }
// ── Constants ──────────────────────────────────────────────────────────────
const DEGREE_LABELS: Record<string, string> = {
general: 'عمومی', specialist: 'متخصص',
expert: 'فوق تخصص', subspecialistplus: 'فلوشیپ',
};
const DEGREE_OPTIONS = [
{ value: 'general', label: 'عمومی' },
{ value: 'specialist', label: 'متخصص' },
{ value: 'expert', label: 'فوق تخصص' },
{ value: 'subspecialistplus', label: 'فلوشیپ' },
];
const GENDER_OPTIONS = [{ value: 'man', label: 'مرد' }, { value: 'woman', label: 'زن' }];
const SPECIALTY_COLORS = [
'bg-[var(--danger-bg)] text-[var(--danger)]',
'bg-[var(--info-bg)] text-[var(--info)]',
'bg-[var(--success-bg)] text-[var(--success)]',
'bg-[var(--violet-bg)] text-[var(--violet)]',
'bg-[var(--accent-bg)] text-[var(--accent)]',
'bg-[var(--violet-bg)] text-[var(--violet)]',
];
// ── Image upload ───────────────────────────────────────────────────────────
async function uploadDoctorImage(file: File): Promise<ImageFileData> {
const token = useAuthStore.getState().token;
const res = await fetch('/file/upload/clinic_pro/doctor/field_image', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${file.name}"`,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: file,
});
const json = await res.json().catch(() => ({}));
if (res.status === 401) {
useAuthStore.getState().logout();
window.location.replace('/admin/login');
throw new Error('نشست منقضی شده است');
}
if (!res.ok) throw new Error(json?.errors?.[0]?.message ?? 'خطا در آپلود تصویر');
return json.data as ImageFileData;
}
// ── Avatar with upload ─────────────────────────────────────────────────────
function DoctorAvatar({ name, img, idx, onUpload, uploading }: {
name: string; img: string | null; idx: number;
onUpload?: (f: File) => void; uploading: boolean;
}) {
const ref = useRef<HTMLInputElement>(null);
const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr';
return (
<div
className={`relative shrink-0 group ${onUpload ? 'cursor-pointer' : ''}`}
onClick={() => onUpload && ref.current?.click()}
>
{img
? <img src={img} alt={name} className="w-24 h-24 rounded-2xl object-cover shadow-xl ring-4 ring-[var(--surface)]" />
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${avatarGradient(idx)} flex items-center justify-center text-[var(--on-primary)] text-3xl font-bold shadow-xl ring-4 ring-[var(--surface)]`}>{initials}</div>
}
{onUpload && (
<div className="absolute inset-0 rounded-2xl bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploading
? <span className="w-6 h-6 border-2 border-[var(--surface)]/40 border-t-white rounded-full animate-spin" />
: <CameraIcon className="w-7 h-7 text-[var(--on-primary)]" />
}
</div>
)}
{onUpload && (
<input ref={ref} type="file" accept="image/*" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} />
)}
</div>
);
}
// ── Info Card ──────────────────────────────────────────────────────────────
function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }: {
icon: React.ElementType; label: string; value: React.ReactNode;
mono?: boolean; copyable?: boolean;
}) {
return (
<div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] hover:bg-[var(--surface-2)] transition-colors group">
<div className="w-9 h-9 rounded-lg bg-[var(--surface)] shadow-sm flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-[var(--text-2)]" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-[var(--text-3)] uppercase tracking-wide mb-0.5">{label}</p>
<p className={`text-sm font-medium text-[var(--text)] ${mono ? 'font-mono' : ''}`} dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>}
</p>
</div>
{copyable && typeof value === 'string' && (
<button onClick={() => { navigator.clipboard.writeText(value); toast.success('کپی شد'); }}
className="opacity-0 group-hover:opacity-100 w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--text-2)] transition-all shrink-0">
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
function StarRating({ rate }: { rate: number }) {
return (
<div className="flex items-center gap-1" dir="ltr">
{Array.from({ length: 5 }).map((_, i) =>
i < Math.round(rate)
? <StarSolid key={i} className="w-4 h-4 text-[var(--warning)]" />
: <StarIcon key={i} className="w-4 h-4 text-[var(--text-3)]" />
)}
<span className="text-sm font-medium text-[var(--text-2)] mr-1">{rate.toFixed(1)}</span>
</div>
);
}
// ── Map Picker ─────────────────────────────────────────────────────────────
const IRAN_CENTER: [number, number] = [32.4279, 53.6880];
async function geocodeCityInIran(cityName: string): Promise<[number, number] | null> {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName + ',ایران')}&format=json&countrycodes=ir&limit=1`;
// nominatim گاهی روی اولین فراخوان خالی/۴۲۹ برمی‌گرداند؛ یک retry تا انتخاب اول هم کار کند.
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } });
const data = await res.json();
if (data?.[0]) return [parseFloat(data[0].lat), parseFloat(data[0].lon)];
} catch {
/* تلاش بعدی */
}
if (attempt === 0) await new Promise(r => setTimeout(r, 900));
}
return null;
}
function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) });
return null;
}
function MapController({ flyTarget }: { flyTarget: [number, number] | null }) {
const map = useMap();
// نقشهٔ تازه‌مانت‌شده ابعادش را نگرفته؛ flyTo بی‌اثر می‌ماند تا invalidateSize صدا زده شود.
useEffect(() => {
map.invalidateSize();
}, [map]);
useEffect(() => {
if (!flyTarget) return;
map.invalidateSize();
map.flyTo(flyTarget, 12, { duration: 1.2 });
}, [flyTarget, 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 className="rounded-xl overflow-hidden border border-[var(--border)]" style={{ height: 300 }}>
<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} />
<MapController flyTarget={flyTarget ?? null} />
{pos && <Marker position={pos} />}
</MapContainer>
</div>
);
}
// ── Searchable Select ──────────────────────────────────────────────────────
function SearchableSelect({ options, value, onChange, placeholder, disabled = false }: {
options: { value: number; label: string }[];
value: number | null;
onChange: (value: number | null, label: string | null) => 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.label.includes(q)) : options,
[options, q]
);
const selected = useMemo(() => options.find(o => o.value === value) ?? null, [options, value]);
const openDropdown = () => {
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={openDropdown}
className={`cp-input h-11 flex items-center justify-between text-right w-full ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${!selected ? 'text-[var(--text-3)]' : ''}`}
>
<span className="truncate flex-1">{selected?.label ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon className={`w-4 h-4 shrink-0 mr-2 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && createPortal(
<div ref={dropRef} style={dropStyle}
className="bg-[var(--surface)] border border-[var(--border)] rounded-xl shadow-2xl overflow-hidden">
<div className="p-2 border-b border-[var(--border)]">
<input autoFocus type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="جستجو..." className="cp-input text-sm h-8" />
</div>
<div className="max-h-52 overflow-y-auto">
<button type="button"
onClick={() => { onChange(null, null); setOpen(false); setQ(''); }}
className="w-full text-right px-3 py-2 text-sm text-[var(--text-3)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)]">
{placeholder ?? 'انتخاب کنید'}
</button>
{filtered.map(o => (
<button key={o.value} type="button"
onClick={() => { onChange(o.value, o.label); setOpen(false); setQ(''); }}
className={`w-full text-right px-3 py-2 text-sm hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] ${value === o.value ? 'text-[var(--primary)] dark:text-[var(--primary)] font-medium bg-[var(--primary-soft)] dark:bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]' : 'text-[var(--text)]'}`}>
{o.label}
</button>
))}
{filtered.length === 0 && (
<p className="text-xs text-[var(--text-3)] text-center py-3">نتیجه‌ای یافت نشد</p>
)}
</div>
</div>,
document.body
)}
</div>
);
}
// ── Hierarchical Specialty Picker ──────────────────────────────────────────
function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
selected: number[]; onChange: (ids: number[]) => void;
specialties: SpecialtyOpt[];
}) {
const [q, setQ] = useState('');
const [openParents, setOpenParents] = useState<Set<number>>(new Set());
const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]);
const childMap = useMemo(() => {
const m = new Map<number, SpecialtyOpt[]>();
specialties.filter(s => s.parent_id !== null).forEach(s => {
const arr = m.get(s.parent_id!) ?? [];
arr.push(s);
m.set(s.parent_id!, arr);
});
return m;
}, [specialties]);
const filteredFlat = useMemo(() => q ? specialties.filter(s => s.name.includes(q)) : [], [q, specialties]);
const toggleParent = (id: number) => setOpenParents(prev => {
const n = new Set(prev);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
const toggleSelect = (id: number) =>
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
const selectedLabels = useMemo(
() => selected.map(id => ({ id, name: specialties.find(s => s.id === id)?.name ?? '' })).filter(x => x.name),
[selected, specialties]
);
return (
<div className="border border-[var(--border)] rounded-xl overflow-hidden">
<div className="p-2 border-b border-[var(--border)] bg-[var(--surface-2)]">
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="جستجوی تخصص..."
className="cp-input text-sm h-8" />
</div>
{selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-[var(--border)] flex flex-wrap gap-1">
{selectedLabels.map(({ id, name }) => (
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-[var(--primary-soft2)] dark:bg-[color-mix(in_srgb,var(--primary)_20%,transparent)] text-[var(--primary-700)] dark:text-[var(--primary)]">
{name}
<button type="button" onClick={() => toggleSelect(id)} className="hover:text-[var(--primary-700)] dark:hover:text-[var(--primary-soft2)] leading-none">×</button>
</span>
))}
</div>
)}
<div className="max-h-56 overflow-y-auto">
{q ? (
filteredFlat.length > 0
? filteredFlat.map(s => (
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer">
<input type="checkbox" checked={selected.includes(s.id)} onChange={() => toggleSelect(s.id)}
className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
<span className="text-sm text-[var(--text)]">{s.name}</span>
{s.parent_id !== null && (
<span className="text-[10px] text-[var(--text-3)] mr-auto truncate max-w-[100px]">
{specialties.find(p => p.id === s.parent_id)?.name}
</span>
)}
</label>
))
: <p className="text-xs text-[var(--text-3)] text-center py-4">نتیجه‌ای یافت نشد</p>
) : (
parents.map(parent => {
const children = childMap.get(parent.id) ?? [];
const isOpen = openParents.has(parent.id);
const numSel = children.filter(c => selected.includes(c.id)).length;
return (
<div key={parent.id}>
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer select-none"
onClick={() => children.length > 0 ? toggleParent(parent.id) : toggleSelect(parent.id)}>
{children.length > 0
? (isOpen
? <ChevronDownIcon className="w-4 h-4 text-[var(--text-3)] shrink-0" />
: <ChevronRightIcon className="w-4 h-4 text-[var(--text-3)] shrink-0" />)
: <input type="checkbox" checked={selected.includes(parent.id)} readOnly
className="rounded border-[var(--border-2)] text-[var(--primary)] pointer-events-none shrink-0" />
}
<span className={`text-sm font-medium ${numSel > 0 ? 'text-[var(--primary)] dark:text-[var(--primary)]' : 'text-[var(--text)]'}`}>
{parent.name}
</span>
{numSel > 0 && (
<span className="mr-auto text-[10px] bg-[var(--primary-soft2)] dark:bg-[color-mix(in_srgb,var(--primary)_20%,transparent)] text-[var(--primary-700)] dark:text-[var(--primary)] px-1.5 py-0.5 rounded-full">
{numSel} انتخاب
</span>
)}
</div>
{isOpen && children.map(child => (
<label key={child.id} className="flex items-center gap-2.5 px-3 py-2 pr-9 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer">
<input type="checkbox" checked={selected.includes(child.id)} onChange={() => toggleSelect(child.id)}
className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
<span className="text-sm text-[var(--text-2)]">{child.name}</span>
</label>
))}
</div>
);
})
)}
</div>
</div>
);
}
// ── Services Picker — filtered by selected specialties ─────────────────────
function ServicesPicker({ selected, onChange, services, specialties, selectedSpecialtyIds }: {
selected: number[]; onChange: (ids: number[]) => void;
services: ServiceOpt[]; specialties: SpecialtyOpt[]; selectedSpecialtyIds: number[];
}) {
const [q, setQ] = useState('');
const [activeTab, setActiveTab] = useState<number | 'all'>('all');
// Only services whose specialty is currently selected
const relevantServices = useMemo(() => {
if (selectedSpecialtyIds.length === 0) return [];
return services.filter(s => s.specialty_id !== null && selectedSpecialtyIds.includes(s.specialty_id));
}, [services, selectedSpecialtyIds]);
// Specialties that actually have services
const tabs = useMemo(() => {
const specIds = [...new Set(relevantServices.map(s => s.specialty_id!))];
return specIds.map(id => ({ id, name: specialties.find(s => s.id === id)?.name ?? String(id) }));
}, [relevantServices, specialties]);
const visibleServices = useMemo(() => {
let pool = activeTab === 'all' ? relevantServices : relevantServices.filter(s => s.specialty_id === activeTab);
return q ? pool.filter(s => s.name.includes(q)) : pool;
}, [activeTab, relevantServices, q]);
const toggle = (id: number) =>
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
const selectedLabels = useMemo(
() => selected.map(id => ({ id, name: services.find(s => s.id === id)?.name ?? '' })).filter(x => x.name),
[selected, services]
);
if (selectedSpecialtyIds.length === 0) {
return (
<div className="rounded-xl border border-dashed border-[var(--border-2)] py-8 text-center text-[var(--text-3)] text-sm">
ابتدا تخصص‌هایی را انتخاب کنید تا خدمات نمایش داده شوند
</div>
);
}
if (relevantServices.length === 0) {
return (
<div className="rounded-xl border border-dashed border-[var(--border-2)] py-8 text-center text-[var(--text-3)] text-sm">
خدمتی برای تخصص‌های انتخاب‌شده یافت نشد
</div>
);
}
return (
<div className="border border-[var(--border)] rounded-xl overflow-hidden">
{tabs.length > 1 && (
<div className="flex gap-1 px-2 py-2 border-b border-[var(--border)] bg-[var(--surface-2)] overflow-x-auto">
{[{ id: 'all' as const, name: 'همه' }, ...tabs].map(t => (
<button key={String(t.id)} type="button"
onClick={() => setActiveTab(t.id as number | 'all')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${activeTab === t.id ? 'bg-[var(--primary)] text-[var(--on-primary)]' : 'text-[var(--text-2)] hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)]'}`}>
{t.name}
</button>
))}
</div>
)}
<div className="p-2 border-b border-[var(--border)]">
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="جستجوی خدمت..."
className="cp-input text-sm h-8" />
</div>
{selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-[var(--border)] flex flex-wrap gap-1">
{selectedLabels.map(({ id, name }) => (
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-[var(--success-bg)] text-[var(--success)]">
{name}
<button type="button" onClick={() => toggle(id)} className="hover:text-[var(--success)] dark:hover:text-[var(--success)] leading-none">×</button>
</span>
))}
</div>
)}
<div className="max-h-44 overflow-y-auto">
{visibleServices.map(svc => (
<label key={svc.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer">
<input type="checkbox" checked={selected.includes(svc.id)} onChange={() => toggle(svc.id)}
className="rounded border-[var(--border-2)] text-[var(--success)] shrink-0" />
<span className="text-sm text-[var(--text)]">{svc.name}</span>
</label>
))}
{visibleServices.length === 0 && q && (
<p className="text-xs text-[var(--text-3)] text-center py-4">نتیجه‌ای یافت نشد</p>
)}
</div>
</div>
);
}
// ── Address Modal ──────────────────────────────────────────────────────────
const addrSchema = z.object({
name: z.string().optional(),
address: z.string().min(1, 'آدرس کامل اجباری است'),
telephone: z.string().min(1, 'تلفن اجباری است').max(20),
province_id: z.number().nullable().optional(),
city_id: z.number().nullable().optional(),
latitude: z.string().optional(),
longitude: z.string().optional(),
}).superRefine((data, ctx) => {
if (!data.province_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'استان اجباری است', path: ['province_id'] });
}
if (!data.city_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'شهر اجباری است', path: ['city_id'] });
}
});
type AddrForm = z.infer<typeof addrSchema>;
function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
open: boolean; onClose: () => void;
existing: AddressData | null; doctorUuid: string;
onSaved: () => void;
}) {
const { register, handleSubmit, watch, setValue, reset, formState: { errors } } = useForm<AddrForm>({
resolver: zodResolver(addrSchema),
});
const provinceId = watch('province_id');
const cityId = watch('city_id');
const latStr = watch('latitude');
const lngStr = watch('longitude');
const lat = latStr ? parseFloat(latStr) : null;
const lng = lngStr ? parseFloat(lngStr) : null;
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
const provincesQ = useQuery({
queryKey: ['provinces'],
queryFn: () => api.get<ApiResponse<any>>('/api/v1/provinces'),
staleTime: 600_000, enabled: open,
});
const citiesQ = useQuery({
queryKey: ['cities', provinceId],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/cities?province_id=${provinceId}`),
enabled: open && !!provinceId, staleTime: 300_000,
});
const provinces: ProvinceOpt[] = useMemo(
() => provincesQ.data?.data?.data ?? provincesQ.data?.data ?? [], [provincesQ.data]);
const cities: CityOpt[] = useMemo(
() => citiesQ.data?.data?.data ?? citiesQ.data?.data ?? [], [citiesQ.data]);
useEffect(() => {
if (open) {
reset({
name: existing?.name ?? '',
address: existing?.address ?? '',
telephone: existing?.telephone ?? '',
province_id: existing?.province ? Number(existing.province.id) : null,
city_id: existing?.city ? Number(existing.city.id) : null,
latitude: existing?.map?.latitude ?? '',
longitude: existing?.map?.longitude ?? '',
});
}
}, [open, existing, reset]);
const saveMut = useMutation({
mutationFn: (values: AddrForm) => {
const body: Record<string, unknown> = {
name: values.name || undefined,
address: values.address || undefined,
telephone: values.telephone || undefined,
province_id: values.province_id ?? undefined,
city_id: values.city_id ?? undefined,
latitude: values.latitude ? parseFloat(values.latitude) : undefined,
longitude: values.longitude ? parseFloat(values.longitude) : undefined,
};
if (existing) return api.patch<ApiResponse<any>>(`/api/v1/clinic-pro/doctor-address/${existing.id}`, body);
return api.post<ApiResponse<any>>('/api/v1/clinic-pro/doctor-address', { ...body, doctor_uuid: doctorUuid });
},
onSuccess: () => { toast.success(existing ? 'آدرس ویرایش شد' : 'آدرس اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش آدرس' : 'افزودن آدرس جدید'} size="lg" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button form="addr-form" type="submit" disabled={saveMut.isPending} className="btn primary sm">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<form id="addr-form" onSubmit={handleSubmit(v => saveMut.mutate(v))} className="space-y-4">
{/* Row 1: Name + Phone */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">نام مطب / کلینیک</label>
<input type="text" className="input" placeholder="مثال: کلینیک مهر" {...register('name')} />
</div>
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
تلفن <span className="text-[var(--danger)]">*</span>
</label>
<input className="cp-input text-left" placeholder="021..." {...latinDigitsField(register('telephone'))} />
{errors.telephone && <p className="text-xs text-[var(--danger)] mt-1">{errors.telephone.message}</p>}
</div>
</div>
{/* Row 2: Full address */}
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
آدرس کامل <span className="text-[var(--danger)]">*</span>
</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
{errors.address && <p className="text-xs text-[var(--danger)] mt-1">{errors.address.message}</p>}
</div>
{/* Row 3: Province + City side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
استان <span className="text-[var(--danger)]">*</span>
</label>
<SearchableSelect
options={provinces.map(p => ({ value: p.id, label: p.name }))}
value={provinceId ?? null}
placeholder="انتخاب استان"
onChange={(val) => {
setValue('province_id', val);
setValue('city_id', null);
setMapFlyTarget(null);
}}
/>
{errors.province_id && <p className="text-xs text-[var(--danger)] mt-1">{errors.province_id.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
شهر <span className="text-[var(--danger)]">*</span>
</label>
<SearchableSelect
options={cities.map(c => ({ value: c.id, label: c.name }))}
value={cityId ?? null}
placeholder={provinceId ? 'انتخاب شهر' : 'ابتدا استان انتخاب کنید'}
disabled={!provinceId}
onChange={(val, label) => {
setValue('city_id', val);
if (label) {
geocodeCityInIran(label).then(coords => {
if (coords) setMapFlyTarget(coords);
});
}
}}
/>
{errors.city_id && <p className="text-xs text-[var(--danger)] mt-1">{errors.city_id.message}</p>}
</div>
</div>
{/* Row 4: Map */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-[var(--text)]">
موقعیت روی نقشه
</label>
{lat && lng && (
<span className="text-xs text-[var(--text-2)] font-mono" dir="ltr">
{lat.toFixed(5)}, {lng.toFixed(5)}
</span>
)}
</div>
<p className="text-xs text-[var(--text-3)] mb-2">
برای دقت بیشتر روی نقشه کلیک کنید
</p>
<MapPicker lat={lat} lng={lng} flyTarget={mapFlyTarget}
onChange={(lt, ln) => { setValue('latitude', String(lt)); setValue('longitude', String(ln)); }} />
{lat && lng && (
<button type="button"
onClick={() => { setValue('latitude', ''); setValue('longitude', ''); }}
className="mt-2 text-xs text-[var(--danger)] hover:underline flex items-center gap-1">
<XMarkIcon className="w-3 h-3" />حذف موقعیت
</button>
)}
</div>
</form>
</Modal>
);
}
// ── Address Card ───────────────────────────────────────────────────────────
function AddressCard({ addr, onEdit, onDelete, readOnly = false }: {
addr: AddressData; onEdit: () => void; onDelete: () => void; readOnly?: boolean;
}) {
return (
<div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] border border-[var(--border)] group">
<div className="w-9 h-9 rounded-lg bg-[var(--surface)] shadow-sm flex items-center justify-center shrink-0 mt-0.5">
<MapPinIcon className="w-4 h-4 text-[var(--text-3)]" />
</div>
<div className="flex-1 min-w-0">
{addr.name && <p className="text-sm font-semibold text-[var(--text)] mb-0.5">{addr.name}</p>}
{addr.address && <p className="text-xs text-[var(--text-2)] leading-relaxed">{addr.address}</p>}
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1.5">
{addr.telephone && (
<span className="text-xs text-[var(--text-2)] flex items-center gap-1 font-mono" dir="ltr">
<PhoneIcon className="w-3 h-3" />{addr.telephone}
</span>
)}
{addr.province && (
<span className="text-xs text-[var(--text-2)]">
{addr.province.name}{addr.city ? ` — ${addr.city.name}` : ''}
</span>
)}
{addr.map.latitude && addr.map.longitude && (
<span className="text-xs text-[var(--text-3)] flex items-center gap-1" dir="ltr">
<MapPinIcon className="w-3 h-3" />{parseFloat(addr.map.latitude).toFixed(4)}, {parseFloat(addr.map.longitude).toFixed(4)}
</span>
)}
</div>
</div>
{!readOnly && (
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
<button onClick={onEdit}
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--info)] hover:bg-[var(--info-bg)] dark:hover:bg-[var(--info)]/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={onDelete}
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10 transition-colors">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
// ── Edit form helpers ──────────────────────────────────────────────────────
const EDIT_DEGREE_OPTIONS = [
{ value: 'general', label: 'پزشک عمومی' },
{ value: 'specialist', label: 'متخصص' },
{ value: 'expert', label: 'فوق تخصص' },
{ value: 'subspecialistplus', label: 'فلوشیپ' },
];
const EDIT_GENDER_OPTIONS = [
{ value: 'man', label: 'مرد', icon: '♂' },
{ value: 'woman', label: 'زن', icon: '♀' },
];
function EditField({ label, required, error, hint, children }: {
label: string; required?: boolean; error?: string; hint?: string; children: React.ReactNode;
}) {
return (
<div>
<label className="cp-label">
{label}{required && <span style={{ color: 'var(--danger)', marginRight: 2 }}>*</span>}
</label>
{children}
{hint && !error && <p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 5 }}>{hint}</p>}
{error && <p style={{ fontSize: 12, color: 'var(--danger)', marginTop: 5 }}>{error}</p>}
</div>
);
}
function EditSectionHeader({ icon, title, badge }: { icon: React.ReactNode; title: string; badge?: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, paddingBottom: 14, borderBottom: '1px solid var(--border)' }}>
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{icon}
</div>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{title}</span>
{badge && (
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--primary-700)', background: 'var(--primary-soft)', padding: '2px 10px', borderRadius: 999 }}>
{badge}
</span>
)}
</div>
);
}
interface EditSelectedEntry { parentId: number | null; childId: number }
function EditSpecialtyPicker({ selected, onChange, specialties }: {
selected: number[]; onChange: (ids: number[]) => void;
specialties: SpecialtyOpt[];
}) {
const [activeParentId, setActiveParentId] = useState<number | null>(null);
const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]);
const childMap = useMemo(() => {
const m: Record<number, SpecialtyOpt[]> = {};
specialties.forEach(s => {
if (s.parent_id !== null) {
if (!m[s.parent_id]) m[s.parent_id] = [];
m[s.parent_id].push(s);
}
});
return m;
}, [specialties]);
const activeChildren = activeParentId !== null ? (childMap[activeParentId] ?? []) : [];
const selectedChildOfParent = (parentId: number): number | null => {
const kids = childMap[parentId] ?? [];
return kids.find(k => selected.includes(k.id))?.id ?? null;
};
const isRootSelected = (parentId: number): boolean =>
!(childMap[parentId]?.length) && selected.includes(parentId);
const selectChild = (child: SpecialtyOpt) =>
onChange(toggleSpecialtyChild(selected, child.id, child.parent_id!, childMap));
const selectRoot = (root: SpecialtyOpt) => onChange(toggleSpecialtyRoot(selected, root.id));
const removeEntry = ({ parentId, childId }: EditSelectedEntry) =>
onChange(removeSpecialtyEntry(selected, childId, parentId, childMap));
const chips: EditSelectedEntry[] = useMemo(() => {
return selected
.map(id => {
const s = specialties.find(x => x.id === id);
if (!s) return null;
if (s.parent_id !== null) return { parentId: s.parent_id, childId: s.id };
const hasSelectedChild = (childMap[s.id] ?? []).some(k => selected.includes(k.id));
if (!hasSelectedChild && !(childMap[s.id]?.length)) return { parentId: null, childId: s.id };
return null;
})
.filter(Boolean) as EditSelectedEntry[];
}, [selected, specialties, childMap]);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{chips.length > 0 && (
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{chips.map(({ parentId, childId }) => {
const child = specialties.find(x => x.id === childId);
const parent = parentId !== null ? specialties.find(x => x.id === parentId) : null;
if (!child) return null;
const label = parent ? `${parent.name}${child.name}` : child.name;
return (
<span key={childId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, padding: '3px 10px 3px 6px', borderRadius: 999, background: 'var(--primary-soft2)', color: 'var(--primary-700)', fontWeight: 600 }}>
{label}
<button type="button" onClick={() => removeEntry({ parentId, childId })}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16, borderRadius: '50%', background: 'var(--primary-soft)', border: 'none', cursor: 'pointer', color: 'var(--primary-600)', padding: 0, fontSize: 12, fontWeight: 700 }}>×</button>
</span>
);
})}
</div>
)}
<div style={{ display: 'flex', minHeight: 240 }}>
<div style={{ width: '45%', borderLeft: '1px solid var(--border)', overflowY: 'auto', maxHeight: 300 }}>
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
گروه تخصصی
</div>
{parents.map(p => {
const hasChildren = (childMap[p.id]?.length ?? 0) > 0;
const childSel = hasChildren ? selectedChildOfParent(p.id) : null;
const rootSel = !hasChildren && isRootSelected(p.id);
const isMarked = childSel !== null || rootSel;
const isActive = activeParentId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => hasChildren ? setActiveParentId(p.id) : selectRoot(p)}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', fontSize: 13, border: 'none',
cursor: 'pointer',
background: isActive ? 'var(--primary-soft)' : rootSel ? 'var(--primary-soft)' : 'transparent',
color: (isActive || rootSel) ? 'var(--primary-700)' : 'var(--text)',
fontWeight: (isActive || rootSel) ? 700 : 400,
borderRight: (isActive || rootSel) ? '3px solid var(--primary)' : '3px solid transparent',
transition: 'all .14s',
}}
>
<span style={{ flex: 1, lineHeight: 1.4 }}>{p.name}</span>
{isMarked && !rootSel && (
<span style={{ fontSize: 11, fontWeight: 700, background: 'var(--primary)', color: 'var(--on-primary)', borderRadius: 999, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
</span>
)}
{rootSel && <CheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />}
{hasChildren && <ChevronLeftIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />}
</button>
);
})}
</div>
<div style={{ flex: 1, overflowY: 'auto', maxHeight: 300 }}>
{activeParentId === null ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13, gap: 8, padding: 24 }}>
<HeartIcon style={{ width: 32, height: 32, opacity: .3 }} />
<span>یک گروه تخصصی انتخاب کنید</span>
</div>
) : activeChildren.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13 }}>
زیرمجموعه‌ای یافت نشد
</div>
) : (
<>
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
انتخاب تخصص چند مورد مجاز
</div>
{activeChildren.map(s => {
const checked = selected.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => selectChild(s)}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', border: 'none', cursor: 'pointer',
background: checked ? 'var(--primary-soft)' : 'transparent',
borderBottom: '1px solid var(--border)',
transition: 'background .12s',
}}
>
<div style={{ width: 18, height: 18, borderRadius: '50%', border: `2px solid ${checked ? 'var(--primary)' : 'var(--border-2)'}`, background: checked ? 'var(--primary)' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'all .12s' }}>
{checked && <div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--on-primary)' }} />}
</div>
<span style={{ fontSize: 13, color: checked ? 'var(--primary-700)' : 'var(--text)', fontWeight: checked ? 600 : 400 }}>{s.name}</span>
</button>
);
})}
</>
)}
</div>
</div>
</div>
);
}
// ── Edit schema ────────────────────────────────────────────────────────────
const urlOrEmpty = z.string().url('آدرس نامعتبر است').optional().or(z.literal(''));
const editSchema = z.object({
name: z.string().min(2, 'نام حداقل ۲ کاراکتر'),
gender: z.enum(['man', 'woman']).optional().or(z.literal('')),
degree: z.string().optional().or(z.literal('')),
medical_system_code: z.string().max(30).optional().or(z.literal('')),
mobile_number: iranMobileOptionalSchema.optional(),
info: z.string().max(2000).optional().or(z.literal('')),
specialties: z.array(z.number()).optional(),
services: z.array(z.number()).optional(),
social_instagram: urlOrEmpty,
social_telegram: urlOrEmpty,
social_aparat: urlOrEmpty,
social_youtube: urlOrEmpty,
social_linkedin: urlOrEmpty,
});
type EditForm = z.infer<typeof editSchema>;
// ── Clinic Invitations Section ─────────────────────────────────────────────
interface InvitationItem {
uuid: string;
status: string;
invited_name: string | null;
invited_specialty: string | null;
invited_at: number;
expires_at: number;
clinic: { uuid: string; name: string; logo: string | null };
}
function ClinicInvitationsSection() {
const qc = useQueryClient();
const [respondingUuid, setRespondingUuid] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['doctor-my-invitations'],
queryFn: () => api.get<ApiResponse<InvitationItem[]>>('/api/v1/doctor/invitations'),
staleTime: 30_000,
});
const invitations: InvitationItem[] = useMemo(() => {
const raw = data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [data]);
const respondMut = useMutation({
mutationFn: ({ uuid, action }: { uuid: string; action: 'accept' | 'reject' }) =>
api.post<ApiResponse<any>>(`/api/v1/doctor/invitation/${uuid}/respond`, { action }),
onSuccess: (_, vars) => {
toast.success(vars.action === 'accept' ? 'دعوتنامه پذیرفته شد' : 'دعوتنامه رد شد');
setRespondingUuid(null);
qc.invalidateQueries({ queryKey: ['doctor-my-invitations'] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!isLoading && invitations.length === 0) return null;
return (
<div className="cp-card p-6">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<BuildingOfficeIcon style={{ width: 16, height: 16 }} />
</div>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>دعوت‌نامه‌های کلینیک</span>
{invitations.length > 0 && (
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--primary-700)', background: 'var(--primary-soft)', padding: '2px 10px', borderRadius: 999 }}>
{invitations.length} دعوت جدید
</span>
)}
</div>
{isLoading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{[1, 2].map(i => <div key={i} className="skeleton" style={{ height: 72, borderRadius: 'var(--r)' }} />)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{invitations.map(inv => {
const isExpired = Date.now() / 1000 > inv.expires_at;
const busy = respondMut.isPending && respondingUuid === inv.uuid;
return (
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
{/* Logo */}
<div style={{ width: 44, height: 44, borderRadius: 10, overflow: 'hidden', flexShrink: 0, background: 'var(--surface)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{inv.clinic.logo
? <img src={inv.clinic.logo} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <BuildingOfficeIcon style={{ width: 20, height: 20, color: 'var(--text-3)' }} />
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', marginBottom: 3 }}>{inv.clinic.name}</p>
{inv.invited_specialty && (
<p style={{ fontSize: 12, color: 'var(--text-2)' }}>تخصص: {inv.invited_specialty}</p>
)}
<p style={{ fontSize: 11, color: isExpired ? 'var(--danger)' : 'var(--text-3)', marginTop: 2 }}>
{isExpired ? 'منقضی شده' : `انقضا: ${new Date(inv.expires_at * 1000).toLocaleDateString('fa-IR')}`}
</p>
</div>
{/* Actions */}
{!isExpired && (
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
disabled={busy}
onClick={() => { setRespondingUuid(inv.uuid); respondMut.mutate({ uuid: inv.uuid, action: 'reject' }); }}
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: '1.5px solid var(--border)', background: 'var(--surface)', color: 'var(--text-2)', fontSize: 13, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.5 : 1 }}>
رد
</button>
<button
disabled={busy}
onClick={() => { setRespondingUuid(inv.uuid); respondMut.mutate({ uuid: inv.uuid, action: 'accept' }); }}
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: '1.5px solid var(--primary)', background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 13, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.5 : 1 }}>
{busy ? '...' : 'پذیرفتن'}
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
// ── Main Page ──────────────────────────────────────────────────────────────
export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfile?: boolean }) {
const { uuid: paramUuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
const doctorUuid = useAuthStore(s => s.doctorUuid);
const context = useAuthStore(s => s.context);
const availableContexts = useAuthStore(s => s.availableContexts);
const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid;
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
// صفحهٔ پزشک در پنل کلینیک، تنظیمات نوبت‌دهیِ همان کلینیک را ویرایش می‌کند — نه
// برنامهٔ مطب شخصی پزشک، که فقط خودش به آن دسترسی دارد.
const scheduleClinicUuid = useMemo(() => {
if (isOwnProfile) return null;
if (context?.type === 'clinic') return dbUuid;
return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
}, [isOwnProfile, context, dbUuid, availableContexts]);
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleConfirm, setToggleConfirm] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [uploadingImg, setUploadingImg] = useState(false);
const [cropState, setCropState] = useState<{ src: string; name: string } | null>(null);
const [addrModalOpen, setAddrModalOpen] = useState(false);
const [editingAddr, setEditingAddr] = useState<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(null);
const [editGender, setEditGender] = useState<'man' | 'woman' | ''>('');
const [editActivityDate, setEditActivityDate] = useState('');
const [repModalOpen, setRepModalOpen] = useState(false);
const [selRepId, setSelRepId] = useState<number | null>(null);
const isAdmin = primaryRole === 'admin';
// ── Queries ──
const { data, isLoading, isError } = useQuery({
queryKey: ['doctor-detail', uuid, primaryRole],
queryFn: () =>
primaryRole === 'clinic'
? api.get<ApiResponse<any>>(`/api/v1/clinic/my-doctor/${uuid}`)
: api.get<ApiResponse<any>>(`/api/v1/doctor/${uuid}`),
enabled: !!uuid,
});
const specialtiesQ = useQuery({
queryKey: ['specialties-all'],
queryFn: () => api.get<ApiResponse<any>>('/api/v1/specialties'),
staleTime: 600_000,
});
const servicesQ = useQuery({
queryKey: ['doctor-services-all'],
queryFn: () => api.get<ApiResponse<any>>('/api/v1/doctor-services'),
staleTime: 600_000,
});
const doctor: DoctorDetail | undefined = useMemo(() => {
const raw = data?.data;
return (raw as any)?.data ?? raw;
}, [data]);
const specialties: SpecialtyOpt[] = useMemo(
() => specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [], [specialtiesQ.data]);
const services: ServiceOpt[] = useMemo(
() => servicesQ.data?.data?.data ?? servicesQ.data?.data ?? [], [servicesQ.data]);
// ── Edit form ──
const { register, handleSubmit, reset, watch, setValue, control: editControl } = useForm<EditForm>({
resolver: zodResolver(editSchema),
});
const watchedSpecialties = (watch('specialties') ?? []) as number[];
const watchedServices = (watch('services') ?? []) as number[];
useEffect(() => {
if (doctor) {
reset({
name: doctor.name,
gender: (doctor.gender as any) ?? '',
degree: doctor.degree ?? '',
medical_system_code: doctor.medical_system_code ?? '',
mobile_number: '',
info: doctor.detail ?? '',
specialties: doctor.specialties.map(s => Number(s.id)),
services: doctor.expertise.map(s => Number(s.id)),
social_instagram: doctor.social_media?.instagram ?? '',
social_telegram: doctor.social_media?.telegram ?? '',
social_aparat: doctor.social_media?.aparat ?? '',
social_youtube: doctor.social_media?.youtube ?? '',
social_linkedin: doctor.social_media?.linkedin ?? '',
});
setEditGender((doctor.gender as any) ?? '');
setEditActivityDate(
doctor.activity_time ? toGregorianDate(toDate(Number(doctor.activity_time))!) : ''
);
}
}, [doctor, reset]);
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return;
const close = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-dmenu]') === null) setMenuOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [menuOpen]);
// ── Mutations ──
const updateMut = useMutation({
mutationFn: (body: EditForm) => api.patch<ApiResponse<any>>(`/api/v1/doctor/${uuid}`, {
title: body.name,
gender: editGender || undefined,
degree: body.degree || undefined,
medical_system_code: body.medical_system_code || undefined,
mobile_number: body.mobile_number || undefined,
info: body.info || undefined,
...(editActivityDate
? { activity_time: Math.floor(new Date(`${editActivityDate}T12:00:00`).getTime() / 1000) }
: {}),
specialties: body.specialties ?? [],
doctor_services: body.services ?? [],
social_media: {
instagram: body.social_instagram || null,
telegram: body.social_telegram || null,
aparat: body.social_aparat || null,
youtube: body.social_youtube || null,
linkedin: body.social_linkedin || null,
},
}),
onSuccess: () => {
toast.success('اطلاعات پزشک بروزرسانی شد');
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
},
onError: (e: Error) => toast.error(e.message),
});
const toggleMut = useMutation({
mutationFn: () => api.post<ApiResponse<any>>(`/api/v1/admin/doctors/${uuid}/status`, {}),
onSuccess: (res) => {
const isActive = (res?.data as any)?.is_active;
toast.success(isActive ? 'پزشک فعال شد' : 'پزشک غیرفعال شد');
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
},
onError: (e: Error) => toast.error(e.message),
});
const deleteMut = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/doctor/${uuid}`),
onSuccess: () => { toast.success('پزشک حذف شد'); navigate('/admin/doctors'); },
onError: (e: Error) => toast.error(e.message),
});
const repsQuery = useQuery({
queryKey: ['representations-select'],
queryFn: () => api.get<ApiResponse<any>>('/api/v1/admin/representations?limit=200'),
enabled: isAdmin && repModalOpen,
staleTime: 60_000,
});
const repOptions: { value: number; label: string }[] = useMemo(() => {
const rows = repsQuery.data?.data ?? [];
return (rows as any[]).map((r) => ({ value: r.id as number, label: `${r.full_name}${r.city ? ` — ${r.city}` : ''}` }));
}, [repsQuery.data]);
const setRepMut = useMutation({
mutationFn: (representationId: number | null) =>
api.put<ApiResponse<any>>(`/api/v1/admin/doctors/${uuid}/representation`, { representation_id: representationId }),
onSuccess: () => {
toast.success('نماینده‌ی پزشک به‌روزرسانی شد');
setRepModalOpen(false);
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
},
onError: (e: Error) => toast.error(e.message),
});
const deleteAddrMut = useMutation({
mutationFn: (id: string) => api.delete<ApiResponse<null>>(`/api/v1/clinic-pro/doctor-address/${id}`),
onSuccess: () => {
toast.success('آدرس حذف شد'); setDeletingAddrId(null);
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['available-locations', uuid] });
},
onError: (e: Error) => toast.error(e.message),
});
// ── Image upload ──
const openCrop = useCallback((file: File) => {
setCropState({ src: URL.createObjectURL(file), name: file.name });
}, []);
const closeCrop = useCallback(() => {
setCropState(prev => {
if (prev) URL.revokeObjectURL(prev.src);
return null;
});
}, []);
const handleImageUpload = useCallback(async (file: File) => {
setUploadingImg(true);
try {
const fileData = await uploadDoctorImage(file);
await api.patch(`/api/v1/doctor/${uuid}`, { images: [fileData] });
toast.success('تصویر با موفقیت آپلود شد');
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'خطا در آپلود تصویر');
} finally {
setUploadingImg(false);
}
}, [uuid, qc]);
// ── Loading / Error ──
if (isLoading) {
return (
<div className="animate-slide-up space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg skeleton" /><div className="h-5 w-32 rounded skeleton" />
</div>
<div className="cp-card p-6">
<div className="flex items-start gap-5 mb-8">
<div className="w-24 h-24 rounded-2xl skeleton shrink-0" />
<div className="flex-1 space-y-2 pt-2">
<div className="h-7 w-48 rounded skeleton" />
<div className="flex gap-2 mt-2"><div className="h-5 w-16 rounded-full skeleton" /><div className="h-5 w-14 rounded-full skeleton" /></div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-16 rounded-xl skeleton" />)}
</div>
</div>
</div>
);
}
if (isError || !doctor) {
return (
<div className="animate-slide-up cp-card p-16 flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-[var(--danger-bg)] flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-[var(--danger)]" />
</div>
<p className="font-semibold text-[var(--text)]">پزشک یافت نشد</p>
<button onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary mt-2">
بازگشت به لیست پزشکان
</button>
</div>
);
}
const mainImage = doctor.img?.[0]?.url ?? null;
const idNum = parseInt(doctor.id ?? '0', 10);
const degreeLabel = DEGREE_LABELS[doctor.degree ?? ''] ?? doctor.degree ?? '—';
const rateNum = parseFloat(doctor.point ?? '3.5');
const addresses = doctor.address ?? [];
return (
<div className="animate-slide-up space-y-5">
<BackButton fallback={isOwnProfile ? '/admin/dashboard' : '/admin/doctors'} />
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-[var(--text-2)]">
{isOwnProfile ? (
<>
<button onClick={() => navigate('/admin/dashboard')}
className="flex items-center gap-1.5 hover:text-[var(--text)] dark:hover:text-[var(--text)] transition-colors">
<ArrowRightIcon className="w-4 h-4" />داشبورد
</button>
<span>/</span>
<span className="text-[var(--text)] font-medium">پروفایل من</span>
</>
) : (
<>
<button onClick={() => navigate(primaryRole === 'clinic' ? '/admin/dashboard' : '/admin/doctors')}
className="flex items-center gap-1.5 hover:text-[var(--text)] dark:hover:text-[var(--text)] transition-colors">
<ArrowRightIcon className="w-4 h-4" />{primaryRole === 'clinic' ? 'داشبورد' : 'پزشکان'}
</button>
<span>/</span>
<span className="text-[var(--text)] font-medium">پروفایل پزشک</span>
</>
)}
</div>
{/* Profile Card */}
<div className="cp-card overflow-hidden">
<div className="h-28 bg-gradient-to-l from-[color-mix(in_srgb,var(--info)_20%,transparent)] via-[color-mix(in_srgb,var(--info)_10%,transparent)] to-transparent dark:from-[color-mix(in_srgb,var(--info)_20%,transparent)]" />
<div className="px-6 pb-6 -mt-12 flex flex-col sm:flex-row sm:items-end gap-4">
<div className="flex flex-col items-center gap-1.5">
<DoctorAvatar name={doctor.name} img={mainImage} idx={idNum}
onUpload={primaryRole !== 'clinic' && !isReadOnly ? openCrop : undefined} uploading={uploadingImg} />
{cropState && (
<ImageCropModal
src={cropState.src}
fileName={cropState.name}
onCancel={closeCrop}
onConfirm={(file) => { closeCrop(); handleImageUpload(file); }}
/>
)}
{primaryRole !== 'clinic' && !isReadOnly && (
<span className="text-[10px] text-[var(--text-3)]">کلیک برای تغییر عکس</span>
)}
</div>
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-[var(--text)]">{displayDoctorName(doctor.name)}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Active status badge */}
{doctor.active
? <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--success-bg)] text-[var(--success)]">
<CheckCircleIcon className="w-3 h-3" />فعال
</span>
: <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-2)]">
<XCircleIcon className="w-3 h-3" />غیرفعال
</span>
}
{doctor.degree && (
<span className="inline-flex text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--info-bg)] text-[var(--info)]">{degreeLabel}</span>
)}
{doctor.gender && (
<span className="text-xs text-[var(--text-2)]">{doctor.gender === 'man' ? '♂ مرد' : '♀ زن'}</span>
)}
</div>
{doctor.specialties.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{doctor.specialties.map((s, i) => (
<span key={s.id} className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${SPECIALTY_COLORS[i % SPECIALTY_COLORS.length]}`}>{s.name}</span>
))}
</div>
)}
{doctor.expertise.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{doctor.expertise.map(e => (
<span key={e.id} className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--border)]">{e.name}</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 sm:mb-1 flex-wrap">
{primaryRole !== 'clinic' && !isReadOnly && (
<button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm">
<PencilIcon className="w-4 h-4" />ویرایش
</button>
)}
{/* Toggle active / Delete — فقط ادمین */}
{!isOwnProfile && primaryRole !== 'clinic' && !isReadOnly && (
<>
<button
onClick={() => setToggleConfirm(true)}
disabled={toggleMut.isPending}
className={`inline-flex items-center gap-1.5 text-sm font-medium px-3 h-10 rounded-xl border transition-colors disabled:opacity-50 ${
doctor.active
? 'border-[var(--accent)] text-[var(--accent)] bg-[var(--accent-bg)] hover:bg-[var(--accent-bg)]'
: 'border-[var(--success)] text-[var(--success)] bg-[var(--success-bg)] hover:bg-[var(--success-bg)]'
}`}
>
{toggleMut.isPending
? <span className="w-3.5 h-3.5 border-2 border-current/30 border-t-current rounded-full animate-spin" />
: doctor.active
? <XCircleIcon className="w-4 h-4" />
: <CheckCircleIcon className="w-4 h-4" />
}
{doctor.active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<div className="relative" data-dmenu>
<button data-dmenu onClick={() => setMenuOpen(!menuOpen)}
className="cp-btn-secondary h-10 w-10 flex items-center justify-center px-0">
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{menuOpen && (
<div className="absolute left-0 top-12 z-30 w-52 cp-card shadow-2xl border border-[var(--border)] py-1.5 animate-scale-in" data-dmenu>
<button onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10">
<TrashIcon className="w-4 h-4 shrink-0" />حذف این پزشک
</button>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
{/* Info Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div className="lg:col-span-2 space-y-5">
<div className="cp-card p-6 space-y-4">
<h2 className="text-sm font-semibold text-[var(--text)]">اطلاعات پایه</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoCard icon={UserIcon} label="نام کامل" value={doctor.name} />
<InfoCard icon={AcademicCapIcon} label="کد نظام پزشکی" value={doctor.medical_system_code} mono copyable />
<InfoCard icon={CalendarIcon} label="سابقه (سال)" value={doctor.experience > 0 ? String(doctor.experience) : null} />
<InfoCard icon={StarIcon} label="امتیاز" value={<div dir="ltr"><StarRating rate={rateNum} /></div>} />
<InfoCard
icon={UserIcon}
label="نماینده"
value={
<span className="flex items-center gap-2">
{doctor.representation
? <button type="button" style={{ color: 'var(--info)', textDecoration: 'underline', background: 'none', border: 0, padding: 0, cursor: 'pointer', font: 'inherit' }} onClick={() => navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'}</button>
: <span className="text-[var(--text-3)]">بدون نماینده</span>}
{isAdmin && (
<button type="button" className="btn ghost sm"
onClick={() => { setSelRepId(doctor.representation?.id ?? null); setRepModalOpen(true); }}>
{doctor.representation ? 'تغییر' : 'افزودن نماینده'}
</button>
)}
</span>
}
/>
</div>
</div>
{doctor.detail && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">بیوگرافی</h2>
<p className="text-sm text-[var(--text-2)] leading-relaxed">{doctor.detail}</p>
</div>
)}
{/* Addresses */}
<div className="cp-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-[var(--text)]">
آدرس‌های مطب ({formatNumber(addresses.length)})
</h2>
{!isReadOnly && (
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="flex items-center gap-1.5 text-xs font-medium text-[var(--primary)] dark:text-[var(--primary)] hover:underline">
<PlusIcon className="w-4 h-4" />افزودن آدرس
</button>
)}
</div>
{addresses.length > 0 ? (
<div className="space-y-2">
{addresses.map(addr => (
<AddressCard key={addr.id} addr={addr} readOnly={isReadOnly}
onEdit={() => { setEditingAddr(addr); setAddrModalOpen(true); }}
onDelete={() => setDeletingAddrId(addr.id)}
/>
))}
</div>
) : (
<div className="text-center py-8 text-[var(--text-3)]">
<MapPinIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هنوز آدرسی ثبت نشده است</p>
{!isReadOnly && (
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="mt-3 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline">
اولین آدرس را اضافه کنید
</button>
)}
</div>
)}
</div>
{uuid && !isOwnProfile && <ScheduleSection doctorUuid={uuid} clinicUuid={scheduleClinicUuid} readOnly={isReadOnly} />}
{isOwnProfile && <ClinicInvitationsSection />}
{doctor.clinics && doctor.clinics.length > 0 && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">
کلینیک‌های مرتبط ({formatNumber(doctor.clinics.length)})
</h2>
<div className="space-y-2">
{doctor.clinics.map(c => (
<div key={c.uuid} className="flex items-start gap-3 p-3 rounded-xl bg-[var(--surface-2)]">
<div className="w-8 h-8 rounded-lg bg-[var(--surface)] shadow-sm flex items-center justify-center shrink-0">
<BuildingOfficeIcon className="w-4 h-4 text-[var(--text-3)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text)]">{c.name}</p>
{c.address && <p className="text-xs text-[var(--text-2)] mt-0.5 flex items-center gap-1"><MapPinIcon className="w-3 h-3" />{c.address}</p>}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-5">
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">
تخصص‌ها ({formatNumber(doctor.specialties.length)})
</h2>
{doctor.specialties.length > 0
? <div className="flex flex-wrap gap-1.5">
{doctor.specialties.map((s, i) => (
<span key={s.id} className={`text-xs font-medium px-2.5 py-1 rounded-lg ${SPECIALTY_COLORS[i % SPECIALTY_COLORS.length]}`}>{s.name}</span>
))}
</div>
: <p className="text-sm text-[var(--text-3)]">تخصصی ثبت نشده</p>
}
{!isReadOnly && (
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline text-right">
ویرایش تخصص‌ها
</button>
)}
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">
خدمات ({formatNumber(doctor.expertise.length)})
</h2>
{doctor.expertise.length > 0
? <div className="flex flex-wrap gap-1.5">
{doctor.expertise.map(e => (
<span key={e.id} className="text-xs px-2.5 py-1 rounded-lg bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--border)]">{e.name}</span>
))}
</div>
: <p className="text-sm text-[var(--text-3)]">خدمتی ثبت نشده</p>
}
{!isReadOnly && (
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline text-right">
ویرایش خدمات
</button>
)}
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">امتیاز و رضایت</h2>
<div className="space-y-3">
<div>
<p className="text-xs text-[var(--text-2)] mb-1">امتیاز کلی</p>
<div dir="ltr"><StarRating rate={rateNum} /></div>
</div>
<div>
<p className="text-xs text-[var(--text-2)] mb-1">درصد رضایت</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-[var(--surface-3)]">
<div className="h-full rounded-full bg-[var(--warning)] transition-all" style={{ width: `${doctor.satisfaction}%` }} />
</div>
<span className="text-sm font-medium text-[var(--text)]">{doctor.satisfaction}%</span>
</div>
</div>
</div>
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-[var(--text)] mb-2">UUID</h2>
<div className="flex items-center gap-2">
<p className="text-[11px] font-mono text-[var(--text-2)] break-all flex-1" dir="ltr">{doctor.uuid}</p>
<button onClick={() => { navigator.clipboard.writeText(doctor.uuid); toast.success('UUID کپی شد'); }}
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--text-2)] hover:bg-[var(--surface-2)] transition-colors shrink-0">
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
{primaryRole === 'doctor' && (
<NotificationMobileCard target="doctor" />
)}
</div>
</div>
{/* ── Edit Modal ─────────────────────────────────────────── */}
<Modal open={editOpen} title="ویرایش اطلاعات پزشک" size="lg" onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)} className="cp-btn-secondary">لغو</button>
<button form="edit-doctor-form" type="submit" disabled={updateMut.isPending} className="cp-btn-primary" style={{ minWidth: 130 }}>
{updateMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</>
}
>
<form id="edit-doctor-form" onSubmit={handleSubmit(v => updateMut.mutate(v))}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── Section: اطلاعات حساب ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<EditField label="نام کامل" required error={(register('name') as any)?.formState?.errors?.name?.message}>
<input type="text" className="cp-input" placeholder="مثلاً: حامد حسینی" {...register('name')} />
</EditField>
<EditField label="شماره موبایل مطب">
<MobileInput className="cp-input" {...register('mobile_number')} />
</EditField>
</div>
</div>
{/* ── Section: اطلاعات حرفه‌ای ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader icon={<IdentificationIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حرفه‌ای" />
{/* Gender toggle */}
<div style={{ marginBottom: 14 }}>
<label className="cp-label">جنسیت</label>
<div style={{ display: 'flex', gap: 8 }}>
{EDIT_GENDER_OPTIONS.map(o => (
<button key={o.value} type="button"
onClick={() => setEditGender(editGender === o.value ? '' : o.value as 'man' | 'woman')}
style={{
flex: 1, height: 42, borderRadius: 'var(--r-sm)',
border: `1.5px solid ${editGender === o.value ? 'var(--primary)' : 'var(--border)'}`,
background: editGender === o.value ? 'var(--primary-soft)' : 'var(--surface)',
color: editGender === o.value ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: editGender === o.value ? 700 : 400, fontSize: 14, cursor: 'pointer', transition: 'all .14s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<span style={{ fontSize: 16 }}>{o.icon}</span>{o.label}
</button>
))}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<EditField label="درجه تحصیلی">
<Controller name="degree" control={editControl} render={({ field }) => (
<GlobalSearchableSelect
options={EDIT_DEGREE_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</EditField>
<EditField label="کد نظام پزشکی">
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
</EditField>
</div>
<div style={{ marginTop: 14 }}>
<EditField label="تاریخ شروع فعالیت" hint="مبنای محاسبهٔ سال تجربه">
<PersianDatePicker
value={editActivityDate}
onChange={setEditActivityDate}
placeholder="انتخاب تاریخ"
enableYearPicker
minWidth={200}
/>
</EditField>
</div>
<div style={{ marginTop: 14 }}>
<EditField label="بیوگرافی">
<textarea rows={3} className="cp-textarea" placeholder="معرفی کوتاهی از پزشک..." {...register('info')} style={{ resize: 'none' }} />
</EditField>
</div>
</div>
{/* ── Section: شبکه‌های اجتماعی ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader icon={<DocumentTextIcon style={{ width: 16, height: 16 }} />} title="شبکه‌های اجتماعی" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<EditField label="اینستاگرام">
<input type="text" dir="ltr" className="cp-input" placeholder="https://instagram.com/..." {...register('social_instagram')} />
</EditField>
<EditField label="تلگرام">
<input type="text" dir="ltr" className="cp-input" placeholder="https://t.me/..." {...register('social_telegram')} />
</EditField>
<EditField label="آپارات">
<input type="text" dir="ltr" className="cp-input" placeholder="https://aparat.com/..." {...register('social_aparat')} />
</EditField>
<EditField label="یوتیوب">
<input type="text" dir="ltr" className="cp-input" placeholder="https://youtube.com/..." {...register('social_youtube')} />
</EditField>
<EditField label="لینکدین">
<input type="text" dir="ltr" className="cp-input" placeholder="https://linkedin.com/in/..." {...register('social_linkedin')} />
</EditField>
</div>
</div>
{/* ── Section: تخصص‌ها ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader
icon={<AcademicCapIcon style={{ width: 16, height: 16 }} />}
title="تخصص‌ها"
badge={watchedSpecialties.length > 0 ? `${watchedSpecialties.length} انتخاب شده` : undefined}
/>
{specialtiesQ.isLoading ? (
<div style={{ height: 200, borderRadius: 'var(--r)', overflow: 'hidden' }} className="skeleton" />
) : (
<EditSpecialtyPicker
selected={watchedSpecialties}
onChange={ids => setValue('specialties', ids, { shouldDirty: true })}
specialties={specialties}
/>
)}
</div>
{/* ── Section: خدمات ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader
icon={<DocumentTextIcon style={{ width: 16, height: 16 }} />}
title="خدمات"
badge={watchedServices.length > 0 ? `${watchedServices.length} انتخاب شده` : undefined}
/>
<ServicesPicker
selected={watchedServices}
onChange={ids => setValue('services', ids)}
services={services}
specialties={specialties}
selectedSpecialtyIds={watchedSpecialties}
/>
</div>
</div>
</form>
</Modal>
{uuid && (
<AddressModal
open={addrModalOpen}
onClose={() => { setAddrModalOpen(false); setEditingAddr(null); }}
existing={editingAddr}
doctorUuid={uuid}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['available-locations', uuid] });
}}
/>
)}
<ConfirmDialog
open={!!deletingAddrId} title="حذف آدرس" message="آیا از حذف این آدرس اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteAddrMut.isPending}
onConfirm={() => deletingAddrId && deleteAddrMut.mutate(deletingAddrId)}
onCancel={() => setDeletingAddrId(null)}
/>
<ConfirmDialog
open={toggleConfirm}
title={doctor.active ? 'غیرفعال کردن پزشک' : 'فعال کردن پزشک'}
message={
doctor.active
? `آیا مطمئن هستید که می‌خواهید دکتر "${doctor.name}" را غیرفعال کنید؟ پزشک دیگر قادر به دریافت نوبت نخواهد بود.`
: `آیا می‌خواهید دکتر "${doctor.name}" را فعال کنید؟ پزشک مجدداً قادر به دریافت نوبت خواهد بود.`
}
confirmLabel={doctor.active ? 'بله، غیرفعال کن' : 'بله، فعال کن'}
danger={doctor.active}
loading={toggleMut.isPending}
onConfirm={() => { toggleMut.mutate(); setToggleConfirm(false); }}
onCancel={() => setToggleConfirm(false)}
/>
<ConfirmDialog
open={deleteOpen} title="حذف پزشک"
message={`آیا از حذف دکتر "${doctor.name}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deleteMut.mutate()} onCancel={() => setDeleteOpen(false)}
/>
<Modal open={repModalOpen} title="نماینده‌ی پزشک" size="md" onClose={() => setRepModalOpen(false)}
footer={
<>
<button className="btn ghost sm" onClick={() => setRepModalOpen(false)}>لغو</button>
{doctor.representation && (
<button className="btn danger sm" disabled={setRepMut.isPending}
onClick={() => setRepMut.mutate(null)}>حذف نماینده</button>
)}
<button className="btn primary sm" disabled={setRepMut.isPending || !selRepId}
onClick={() => setRepMut.mutate(selRepId)}>ذخیره</button>
</>
}
>
<label className="block text-sm text-[var(--text-2)] mb-2">انتخاب نماینده</label>
<GlobalSearchableSelect
options={repOptions}
value={selRepId}
onChange={(v) => setSelRepId(v === null || v === '' ? null : Number(v))}
placeholder={repsQuery.isLoading ? 'در حال بارگذاری…' : 'جستجوی نماینده…'}
/>
</Modal>
</div>
);
}