Files
clinicpro/assets/admin/pages/DoctorDetailPage.tsx
T
hamed f1258d206d feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays
- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
2026-07-18 13:32:56 +03:30

1916 lines
93 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 { 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 } 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';
// 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 AVATAR_COLORS = [
'from-blue-500 to-cyan-500', 'from-violet-500 to-purple-600',
'from-emerald-500 to-teal-600', 'from-rose-500 to-pink-600',
'from-amber-500 to-orange-600',
];
const SPECIALTY_COLORS = [
'bg-red-100 dark:bg-red-400/10 text-red-700 dark:text-red-300',
'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300',
'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300',
'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300',
'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300',
'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300',
];
// ── 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-white dark:ring-gray-900" />
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-white text-3xl font-bold shadow-xl ring-4 ring-white dark:ring-gray-900`}>{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-white/40 border-t-white rounded-full animate-spin" />
: <CameraIcon className="w-7 h-7 text-white" />
}
</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-slate-50 dark:bg-gray-800/60 hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors group">
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-slate-500 dark:text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-0.5">{label}</p>
<p className={`text-sm font-medium text-slate-800 dark:text-slate-200 ${mono ? 'font-mono' : ''}`} dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-slate-400 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-slate-400 hover:text-slate-600 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-amber-400" />
: <StarIcon key={i} className="w-4 h-4 text-slate-300 dark:text-slate-600" />
)}
<span className="text-sm font-medium text-slate-600 dark:text-slate-400 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-slate-200 dark:border-gray-700" 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-slate-400 dark:text-slate-500' : ''}`}
>
<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-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-xl shadow-2xl overflow-hidden">
<div className="p-2 border-b border-slate-100 dark:border-gray-700">
<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-slate-400 hover:bg-slate-50 dark:hover:bg-gray-800">
{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-slate-50 dark:hover:bg-gray-800 ${value === o.value ? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-500/10' : 'text-slate-700 dark:text-slate-300'}`}>
{o.label}
</button>
))}
{filtered.length === 0 && (
<p className="text-xs text-slate-400 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-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="p-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60">
<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-slate-100 dark:border-gray-700 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-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
{name}
<button type="button" onClick={() => toggleSelect(id)} className="hover:text-primary-900 dark:hover:text-primary-100 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-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<input type="checkbox" checked={selected.includes(s.id)} onChange={() => toggleSelect(s.id)}
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span>
{s.parent_id !== null && (
<span className="text-[10px] text-slate-400 mr-auto truncate max-w-[100px]">
{specialties.find(p => p.id === s.parent_id)?.name}
</span>
)}
</label>
))
: <p className="text-xs text-slate-400 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-slate-50 dark:hover:bg-gray-800 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-slate-400 shrink-0" />
: <ChevronRightIcon className="w-4 h-4 text-slate-400 shrink-0" />)
: <input type="checkbox" checked={selected.includes(parent.id)} readOnly
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 pointer-events-none shrink-0" />
}
<span className={`text-sm font-medium ${numSel > 0 ? 'text-primary-600 dark:text-primary-400' : 'text-slate-700 dark:text-slate-300'}`}>
{parent.name}
</span>
{numSel > 0 && (
<span className="mr-auto text-[10px] bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 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-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<input type="checkbox" checked={selected.includes(child.id)} onChange={() => toggleSelect(child.id)}
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-slate-600 dark:text-slate-400">{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-slate-300 dark:border-gray-600 py-8 text-center text-slate-400 dark:text-slate-500 text-sm">
ابتدا تخصص‌هایی را انتخاب کنید تا خدمات نمایش داده شوند
</div>
);
}
if (relevantServices.length === 0) {
return (
<div className="rounded-xl border border-dashed border-slate-300 dark:border-gray-600 py-8 text-center text-slate-400 dark:text-slate-500 text-sm">
خدمتی برای تخصص‌های انتخاب‌شده یافت نشد
</div>
);
}
return (
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
{tabs.length > 1 && (
<div className="flex gap-1 px-2 py-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60 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-primary-600 text-white' : 'text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-gray-700'}`}>
{t.name}
</button>
))}
</div>
)}
<div className="p-2 border-b border-slate-100 dark:border-gray-700">
<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-slate-100 dark:border-gray-700 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-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300">
{name}
<button type="button" onClick={() => toggle(id)} className="hover:text-emerald-900 dark:hover:text-emerald-100 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-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<input type="checkbox" checked={selected.includes(svc.id)} onChange={() => toggle(svc.id)}
className="rounded border-slate-300 dark:border-gray-600 text-emerald-600 shrink-0" />
<span className="text-sm text-slate-700 dark:text-slate-300">{svc.name}</span>
</label>
))}
{visibleServices.length === 0 && q && (
<p className="text-xs text-slate-400 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-slate-700 dark:text-slate-300 mb-1.5">نام مطب / کلینیک</label>
<input type="text" className="input" placeholder="مثال: کلینیک مهر" {...register('name')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
تلفن <span className="text-red-500">*</span>
</label>
<input className="cp-input text-left" placeholder="021..." {...latinDigitsField(register('telephone'))} />
{errors.telephone && <p className="text-xs text-red-500 mt-1">{errors.telephone.message}</p>}
</div>
</div>
{/* Row 2: Full address */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
آدرس کامل <span className="text-red-500">*</span>
</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
{errors.address && <p className="text-xs text-red-500 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-slate-700 dark:text-slate-300 mb-1.5">
استان <span className="text-red-500">*</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-red-500 mt-1">{errors.province_id.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
شهر <span className="text-red-500">*</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-red-500 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-slate-700 dark:text-slate-300">
موقعیت روی نقشه
</label>
{lat && lng && (
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono" dir="ltr">
{lat.toFixed(5)}, {lng.toFixed(5)}
</span>
)}
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 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-red-500 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-slate-50 dark:bg-gray-800/60 border border-slate-100 dark:border-gray-700 group">
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0 mt-0.5">
<MapPinIcon className="w-4 h-4 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
{addr.name && <p className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-0.5">{addr.name}</p>}
{addr.address && <p className="text-xs text-slate-600 dark:text-slate-400 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-slate-500 dark:text-slate-400 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-slate-500 dark:text-slate-400">
{addr.province.name}{addr.city ? ` — ${addr.city.name}` : ''}
</span>
)}
{addr.map.latitude && addr.map.longitude && (
<span className="text-xs text-slate-400 dark:text-slate-500 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-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/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-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/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) => {
const parentId = child.parent_id!;
if (selected.includes(child.id)) {
onChange([]);
} else {
onChange([parentId, child.id]);
}
};
const selectRoot = (root: SpecialtyOpt) => {
if (selected.includes(root.id)) {
onChange([]);
} else {
setActiveParentId(null);
onChange([root.id]);
}
};
const removeEntry = () => { onChange([]); setActiveParentId(null); };
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()}
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;
const hasSelection = selected.length > 0;
const isDisabled = hasSelection && !isMarked;
return (
<button
key={p.id}
type="button"
disabled={isDisabled}
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: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.35 : 1,
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-red-100 dark:bg-red-400/10 flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-red-500" />
</div>
<p className="font-semibold text-slate-800 dark:text-slate-200">پزشک یافت نشد</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">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
{isOwnProfile ? (
<>
<button onClick={() => navigate('/admin/dashboard')}
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
<ArrowRightIcon className="w-4 h-4" />داشبورد
</button>
<span>/</span>
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل من</span>
</>
) : (
<>
<button onClick={() => navigate(primaryRole === 'clinic' ? '/admin/dashboard' : '/admin/doctors')}
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
<ArrowRightIcon className="w-4 h-4" />{primaryRole === 'clinic' ? 'داشبورد' : 'پزشکان'}
</button>
<span>/</span>
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل پزشک</span>
</>
)}
</div>
{/* Profile Card */}
<div className="cp-card overflow-hidden">
<div className="h-28 bg-gradient-to-l from-blue-600/20 via-blue-500/10 to-transparent dark:from-blue-500/20" />
<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-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
)}
</div>
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">دکتر {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-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300">
<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-slate-100 dark:bg-gray-700 text-slate-500 dark:text-slate-400">
<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-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300">{degreeLabel}</span>
)}
{doctor.gender && (
<span className="text-xs text-slate-500 dark:text-slate-400">{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-slate-100 dark:bg-gray-700 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-600">{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-orange-300 dark:border-orange-500/40 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-500/10 hover:bg-orange-100 dark:hover:bg-orange-500/20'
: 'border-emerald-300 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20'
}`}
>
{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-slate-200 dark:border-gray-700 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-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/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-slate-700 dark:text-slate-300">اطلاعات پایه</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: '#2563eb', 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-slate-400">بدون نماینده</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-slate-700 dark:text-slate-300 mb-3">بیوگرافی</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 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-slate-700 dark:text-slate-300">
آدرس‌های مطب ({formatNumber(addresses.length)})
</h2>
{!isReadOnly && (
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 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-slate-400 dark:text-slate-500">
<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-primary-600 dark:text-primary-400 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-slate-700 dark:text-slate-300 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-slate-50 dark:bg-gray-800/60">
<div className="w-8 h-8 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
<BuildingOfficeIcon className="w-4 h-4 text-slate-400" />
</div>
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{c.name}</p>
{c.address && <p className="text-xs text-slate-500 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-slate-700 dark:text-slate-300 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-slate-400">تخصصی ثبت نشده</p>
}
{!isReadOnly && (
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
ویرایش تخصص‌ها
</button>
)}
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 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-slate-100 dark:bg-gray-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-700">{e.name}</span>
))}
</div>
: <p className="text-sm text-slate-400">خدمتی ثبت نشده</p>
}
{!isReadOnly && (
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
ویرایش خدمات
</button>
)}
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">امتیاز و رضایت</h2>
<div className="space-y-3">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">امتیاز کلی</p>
<div dir="ltr"><StarRating rate={rateNum} /></div>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">درصد رضایت</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-slate-200 dark:bg-gray-700">
<div className="h-full rounded-full bg-amber-400 transition-all" style={{ width: `${doctor.satisfaction}%` }} />
</div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{doctor.satisfaction}%</span>
</div>
</div>
</div>
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">UUID</h2>
<div className="flex items-center gap-2">
<p className="text-[11px] font-mono text-slate-500 dark:text-slate-400 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-slate-400 hover:text-slate-600 hover:bg-slate-100 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)}
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-slate-600 mb-2">انتخاب نماینده</label>
<GlobalSearchableSelect
options={repOptions}
value={selRepId}
onChange={(v) => setSelRepId(v === null || v === '' ? null : Number(v))}
placeholder={repsQuery.isLoading ? 'در حال بارگذاری…' : 'جستجوی نماینده…'}
/>
</Modal>
</div>
);
}