Files
clinicpro/assets/admin/pages/DoctorDetailPage.tsx
T

2333 lines
114 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 } 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,
} 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 } from '../lib/utils';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
// 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;
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 }[];
}
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; }
interface AddressData {
id: string; uuid: string; name: string | null; address: string | null;
telephone: string | null;
map: { latitude: string | null; longitude: string | null };
city: { id: string; name: string } | null;
province: { id: string; name: string } | null;
}
// ── Schedule types ─────────────────────────────────────────────────────────
interface SlotConfig { start: string; end: string; duration: number; }
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SessionConfig[]; }
interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; }
interface SessionConfig {
active: boolean;
location_id: number | null;
start_time: string;
end_time: string;
duration_per_patient: number;
has_rest: boolean;
rest_interval: number;
time_to_rest: number;
patient_limit: number | null;
}
interface NewDayConfig { sessions: SessionConfig[]; }
type NewScheduleMap = Record<string, NewDayConfig>;
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; }
// ── 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',
];
// ── Persian (Jalali) date utilities ───────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jalaali = require('jalaali-js') as {
toJalaali: (gy: number, gm: number, gd: number) => { jy: number; jm: number; jd: number };
toGregorian: (jy: number, jm: number, jd: number) => { gy: number; gm: number; gd: number };
jalaaliMonthLength: (jy: number, jm: number) => number;
};
const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];
const JALALI_DAYS_SHORT = ['ش','ی','د','س','چ','پ','ج'];
function toPersianNums(n: number | string): string {
return String(n).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
}
function gToJ(gDate: string): { jy: number; jm: number; jd: number } | null {
if (!gDate) return null;
const [gy, gm, gd] = gDate.split('-').map(Number);
return jalaali.toJalaali(gy, gm, gd);
}
function jToG(jy: number, jm: number, jd: number): string {
const { gy, gm, gd } = jalaali.toGregorian(jy, jm, jd);
return `${gy}-${String(gm).padStart(2,'0')}-${String(gd).padStart(2,'0')}`;
}
function formatPersianDate(gDate: string): string {
const j = gToJ(gDate);
if (!j) return gDate;
return `${toPersianNums(j.jd)} ${JALALI_MONTHS[j.jm - 1]} ${toPersianNums(j.jy)}`;
}
function jFirstDayOfWeek(jy: number, jm: number): number {
const { gy, gm, gd } = jalaali.toGregorian(jy, jm, 1);
const dow = new Date(gy, gm - 1, gd).getDay(); // 0=Sunday
return (dow + 1) % 7; // 0=Saturday, 1=Sunday, ..., 6=Friday
}
function todayGregorian(): string { return new Date().toISOString().slice(0, 10); }
// ── Persian Date Picker ────────────────────────────────────────────────────
function PersianDateInput({ value, onChange, minDate, placeholder }: {
value: string; onChange: (v: string) => void; minDate?: string; placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [viewJy, setViewJy] = useState(0);
const [viewJm, setViewJm] = useState(0);
const [rect, setRect] = useState<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const j = gToJ(value || todayGregorian());
if (j) { setViewJy(j.jy); setViewJm(j.jm); }
}, [open, value]);
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 openPicker = () => {
if (!btnRef.current) return;
setRect(btnRef.current.getBoundingClientRect());
setOpen(v => !v);
};
const prevMonth = () => viewJm === 1 ? (setViewJy(y => y - 1), setViewJm(12)) : setViewJm(m => m - 1);
const nextMonth = () => viewJm === 12 ? (setViewJy(y => y + 1), setViewJm(1)) : setViewJm(m => m + 1);
const selectDay = (jd: number) => { onChange(jToG(viewJy, viewJm, jd)); setOpen(false); };
const selectedJ = gToJ(value);
const minJ = minDate ? gToJ(minDate) : null;
const todayJ = gToJ(todayGregorian());
const daysInMonth = viewJy && viewJm ? jalaali.jalaaliMonthLength(viewJy, viewJm) : 0;
const firstDow = viewJy && viewJm ? jFirstDayOfWeek(viewJy, viewJm) : 0;
const isSelected = (jd: number) => !!selectedJ && selectedJ.jy === viewJy && selectedJ.jm === viewJm && selectedJ.jd === jd;
const isToday = (jd: number) => !!todayJ && todayJ.jy === viewJy && todayJ.jm === viewJm && todayJ.jd === jd;
const isDisabled = (jd: number) => {
if (!minJ) return false;
if (viewJy < minJ.jy) return true;
if (viewJy === minJ.jy && viewJm < minJ.jm) return true;
if (viewJy === minJ.jy && viewJm === minJ.jm && jd < minJ.jd) return true;
return false;
};
const dropStyle: React.CSSProperties = rect
? { position: 'fixed', top: rect.bottom + 4, left: rect.left, zIndex: 9999, width: 288 }
: {};
return (
<div>
<button ref={btnRef} type="button" onClick={openPicker}
className="cp-input h-11 flex items-center justify-between w-full cursor-pointer">
<span className={value ? 'text-slate-800 dark:text-slate-200' : 'text-slate-400 dark:text-slate-500'}>
{value ? formatPersianDate(value) : (placeholder ?? 'انتخاب تاریخ')}
</span>
<CalendarIcon className="w-4 h-4 text-slate-400 shrink-0 mr-2" />
</button>
{open && createPortal(
<div ref={dropRef} style={dropStyle}
className="bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-gray-800">
<button type="button" onClick={nextMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 text-slate-500 transition-colors">
<ChevronRightIcon className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-slate-800 dark:text-slate-100" dir="rtl">
{viewJm > 0 ? JALALI_MONTHS[viewJm - 1] : ''} {toPersianNums(viewJy)}
</span>
<button type="button" onClick={prevMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 text-slate-500 transition-colors">
<ChevronLeftIcon className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-7 px-3 pt-2 pb-1">
{JALALI_DAYS_SHORT.map(d => (
<div key={d} className="text-center text-xs font-medium text-slate-400 dark:text-slate-500 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5 px-3 pb-3">
{Array.from({ length: firstDow }).map((_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: daysInMonth }).map((_, i) => {
const jd = i + 1;
const sel = isSelected(jd);
const tod = isToday(jd);
const dis = isDisabled(jd);
return (
<button key={jd} type="button" disabled={dis} onClick={() => selectDay(jd)}
className={`w-full aspect-square flex items-center justify-center rounded-lg text-sm transition-colors
${dis ? 'text-slate-300 dark:text-gray-600 cursor-not-allowed'
: sel ? 'bg-primary-500 text-white font-semibold shadow-sm'
: tod ? 'ring-1 ring-primary-400 text-primary-700 dark:text-primary-300 font-medium'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-800'}`}>
{toPersianNums(jd)}
</button>
);
})}
</div>
</div>,
document.body
)}
</div>
);
}
const SCHEDULE_DAYS = [
{ key: '0', label: 'شنبه' },
{ key: '1', label: 'یکشنبه' },
{ key: '2', label: 'دوشنبه' },
{ key: '3', label: 'سه‌شنبه' },
{ key: '4', label: 'چهارشنبه' },
{ key: '5', label: 'پنجشنبه' },
{ key: '6', label: 'جمعه' },
];
const DURATION_OPTS = [10, 15, 20, 30, 45, 60];
const DEFAULT_SESSION: SessionConfig = {
active: true, location_id: null,
start_time: '09:00', end_time: '13:00',
duration_per_patient: 20,
has_rest: false, rest_interval: 60, time_to_rest: 10,
patient_limit: null,
};
const EMPTY_NEW_SCHEDULE: NewScheduleMap = Object.fromEntries(
SCHEDULE_DAYS.map(d => [d.key, { sessions: [] }])
);
function parseMinutes(t: string): number {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
}
function hasOverlap(sessions: SessionConfig[]): boolean {
const active = sessions.filter(s => s.active);
const sorted = [...active].sort((a, b) => parseMinutes(a.start_time) - parseMinutes(b.start_time));
for (let i = 0; i < sorted.length - 1; i++) {
if (parseMinutes(sorted[i].end_time) > parseMinutes(sorted[i + 1].start_time)) return true;
}
return false;
}
function addMinutes(time: string, mins: number): string {
const total = Math.min(parseMinutes(time) + mins, 23 * 60 + 59);
const h = Math.floor(total / 60).toString().padStart(2, '0');
const m = (total % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
function calcSlotCount(session: SessionConfig): number {
const totalMins = parseMinutes(session.end_time) - parseMinutes(session.start_time);
if (totalMins <= 0 || session.duration_per_patient <= 0) return 0;
const dur = session.duration_per_patient;
const limit = session.patient_limit ?? Infinity;
let current = 0, elapsedWork = 0, count = 0;
while (current + dur <= totalMins) {
if (count >= limit) break;
if (session.has_rest && session.rest_interval > 0 && elapsedWork > 0 && elapsedWork >= session.rest_interval) {
current += session.time_to_rest; elapsedWork = 0; continue;
}
current += dur; elapsedWork += dur; count++;
}
return count;
}
function tsToDate(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
}
// ── 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 cursor-pointer" onClick={() => 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>
}
<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>
<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> {
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName + ',ایران')}&format=json&countrycodes=ir&limit=1`;
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)];
return null;
} catch {
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();
useEffect(() => {
if (flyTarget) 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().optional(),
telephone: z.string().max(20).optional(),
province_id: z.number().nullable().optional(),
city_id: z.number().nullable().optional(),
latitude: z.string().optional(),
longitude: z.string().optional(),
});
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 } = 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">تلفن</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
</div>
</div>
{/* Row 2: Full address */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">آدرس کامل</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
</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">استان</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);
}}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">شهر</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);
});
}
}}
/>
</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 }: {
addr: AddressData; onEdit: () => void; onDelete: () => void;
}) {
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>
<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>
);
}
// ── Schedule components ────────────────────────────────────────────────────
function SlotEditor({ slots, onChange }: {
slots: SlotConfig[];
onChange: (slots: SlotConfig[]) => void;
}) {
const add = () => onChange([...slots, { start: '09:00', end: '13:00', duration: 30 }]);
const remove = (i: number) => onChange(slots.filter((_, idx) => idx !== i));
const update = (i: number, field: keyof SlotConfig, val: string | number) =>
onChange(slots.map((s, idx) => idx === i ? { ...s, [field]: val } : s));
return (
<div className="space-y-2">
{slots.map((slot, i) => (
<div key={i} className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">از</span>
<input type="time" value={slot.start} onChange={e => update(i, 'start', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">تا</span>
<input type="time" value={slot.end} onChange={e => update(i, 'end', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<select value={slot.duration} onChange={e => update(i, 'duration', Number(e.target.value))}
className="cp-select h-8 text-sm w-24">
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)}
</select>
<button type="button" onClick={() => remove(i)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors shrink-0">
<XMarkIcon className="w-3.5 h-3.5" />
</button>
</div>
))}
<button type="button" onClick={add}
className="flex items-center gap-1.5 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-1">
<PlusIcon className="w-3.5 h-3.5" />افزودن بازه زمانی
</button>
</div>
);
}
function SessionEditor({ session, onChange, onRemove, addresses }: {
session: SessionConfig;
onChange: (s: SessionConfig) => void;
onRemove: () => void;
addresses: AddressData[];
}) {
const upd = <K extends keyof SessionConfig>(k: K, v: SessionConfig[K]) =>
onChange({ ...session, [k]: v });
const slotCount = calcSlotCount(session);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', background: 'var(--surface)', overflow: 'hidden' }}>
{/* Time inputs */}
<div style={{ padding: '14px 16px 12px', display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 12, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>از ساعت</div>
<div className="field">
<input type="time" dir="ltr" value={session.start_time}
onChange={e => upd('start_time', e.target.value)} />
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>تا ساعت</div>
<div className="field">
<input type="time" dir="ltr" value={session.end_time}
onChange={e => upd('end_time', e.target.value)} />
</div>
</div>
<button type="button" className="mini-btn danger" onClick={onRemove} style={{ marginBottom: 1 }}>
<TrashIcon style={{ width: 15, height: 15 }} />
</button>
</div>
{/* Duration + Location + Limit */}
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
<div className="field">
<select value={session.duration_per_patient}
onChange={e => upd('duration_per_patient', Number(e.target.value))}>
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)}
</select>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div>
<div className="field">
<select value={session.location_id ?? ''}
onChange={e => upd('location_id', e.target.value ? Number(e.target.value) : null)}>
<option value="">انتخاب کنید</option>
{addresses.map(a => (
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option>
))}
</select>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>حداکثر نوبت</div>
<div className="field">
<input type="number" min={1} placeholder="—"
value={session.patient_limit ?? ''}
onChange={e => upd('patient_limit', e.target.value ? Number(e.target.value) : null)} />
</div>
</div>
</div>
{/* Rest toggle */}
<div style={{
padding: '12px 16px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
}}>
<div>
<b style={{ fontSize: 13 }}>استراحت دوره‌ای بین نوبت‌ها</b>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>توقف خودکار پس از مدت کاری مشخص</div>
</div>
<button type="button" onClick={() => upd('has_rest', !session.has_rest)}
style={{
position: 'relative', width: 40, height: 22, borderRadius: 99,
border: 'none', cursor: 'pointer', flexShrink: 0,
background: session.has_rest ? 'var(--primary)' : 'var(--surface-3)',
transition: 'background .2s',
}}>
<span style={{
position: 'absolute', top: 3, borderRadius: '50%',
width: 16, height: 16, background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,.25)',
insetInlineStart: session.has_rest ? 20 : 3,
}} />
</button>
</div>
{/* Rest params */}
{session.has_rest && (
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
<div className="field">
<input type="number" min={1} value={session.rest_interval}
onChange={e => upd('rest_interval', Number(e.target.value))} />
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>استراحت (دقیقه)</div>
<div className="field">
<input type="number" min={1} value={session.time_to_rest}
onChange={e => upd('time_to_rest', Number(e.target.value))} />
</div>
</div>
</div>
)}
{/* Slot count footer */}
<div style={{
padding: '10px 16px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
background: 'var(--surface-2)',
}}>
<span className="muted" style={{ fontSize: 12 }}>تعداد نوبت محاسبه‌شده در این بازه</span>
{slotCount > 0 ? (
<span className="badge green">
<CheckCircleIcon style={{ width: 13, height: 13 }} />
{slotCount} نوبت
</span>
) : (
<span className="muted" style={{ fontSize: 12 }}></span>
)}
</div>
</div>
);
}
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
const qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
const [expandedDay, setExpandedDay] = useState<string | null>(null);
const scheduleQ = useQuery({
queryKey: ['doctor-schedule', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`),
staleTime: 0,
retry: (count, err) => !(err instanceof ApiError && err.status === 404),
});
useEffect(() => {
if (scheduleQ.data) {
const d: WeeklyScheduleData = scheduleQ.data?.data?.data ?? scheduleQ.data?.data;
if (d?.schedule) {
const merged: NewScheduleMap = { ...EMPTY_NEW_SCHEDULE };
for (const key of Object.keys(d.schedule)) {
const raw = d.schedule[key] as any;
if (raw?.sessions) merged[key] = { sessions: raw.sessions };
}
setScheduleMap(merged);
setScheduleUuid(d.uuid);
}
} else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) {
setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null);
}
}, [scheduleQ.data, scheduleQ.error]);
const overlapDays = useMemo(() =>
Object.fromEntries(SCHEDULE_DAYS.map(d => [d.key, hasOverlap(scheduleMap[d.key]?.sessions ?? [])]))
, [scheduleMap]);
const hasAnyOverlap = Object.values(overlapDays).some(Boolean);
const totalSlots = useMemo(() =>
SCHEDULE_DAYS.reduce((sum, d) => {
const sessions = scheduleMap[d.key]?.sessions ?? [];
return sum + sessions.filter(s => s.active).reduce((s2, s) => s2 + calcSlotCount(s), 0);
}, 0)
, [scheduleMap]);
const missingLocation = addresses.length > 0 && SCHEDULE_DAYS.some(d =>
(scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null)
);
const saveMut = useMutation({
mutationFn: () => {
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است');
return scheduleUuid
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap })
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap });
},
onSuccess: (res) => {
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
toast.success('برنامه هفتگی ذخیره شد');
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] });
},
onError: (e: Error) => toast.error(e.message),
});
const setDaySessions = (key: string, sessions: SessionConfig[]) =>
setScheduleMap(prev => ({ ...prev, [key]: { sessions } }));
const addSession = (key: string) => {
const existing = scheduleMap[key]?.sessions ?? [];
const lastEnd = existing.length > 0
? existing.reduce((max, s) => parseMinutes(s.end_time) > parseMinutes(max) ? s.end_time : max, '00:00')
: '09:00';
const newStart = existing.length > 0 ? addMinutes(lastEnd, 30) : '09:00';
const newEnd = addMinutes(newStart, 240);
const defaultLoc = addresses.length > 0 ? Number(addresses[0].id) : null;
setDaySessions(key, [...existing, { ...DEFAULT_SESSION, start_time: newStart, end_time: newEnd, location_id: defaultLoc }]);
setExpandedDay(key);
};
const removeSession = (key: string, idx: number) =>
setDaySessions(key, (scheduleMap[key]?.sessions ?? []).filter((_, i) => i !== idx));
const updateSession = (key: string, idx: number, s: SessionConfig) =>
setDaySessions(key, (scheduleMap[key]?.sessions ?? []).map((old, i) => i === idx ? s : old));
if (scheduleQ.isLoading) return (
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
);
return (
<div className="space-y-2">
{/* ─ خلاصه کل هفته */}
{totalSlots > 0 && (
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30">
<CalendarIcon className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
)}
{SCHEDULE_DAYS.map(day => {
const sessions = scheduleMap[day.key]?.sessions ?? [];
const isOverlap = overlapDays[day.key];
const isExpanded = expandedDay === day.key;
const activeSessions = sessions.filter(s => s.active);
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
return (
<div key={day.key} className="card" style={{ overflow: 'hidden' }}>
{/* Day header */}
<div
style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}
onClick={() => setExpandedDay(isExpanded ? null : day.key)}
>
<b style={{ fontSize: 14, minWidth: 56, flexShrink: 0 }}>{day.label}</b>
<div style={{ flex: 1, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', minWidth: 0 }}>
{activeSessions.map((s, i) => (
<span key={i} style={{ fontSize: 13, fontFamily: 'monospace', color: 'var(--text-2)', direction: 'ltr', whiteSpace: 'nowrap' }}>
{s.start_time} {s.end_time}
</span>
))}
{sessions.length === 0 && <span className="muted" style={{ fontSize: 12 }}>تعطیل</span>}
</div>
{daySlots > 0 && (
<span className="badge green" style={{ flexShrink: 0 }}>
<span className="bdot" />{daySlots} نوبت
</span>
)}
{isOverlap && (
<span className="badge red" style={{ flexShrink: 0 }}>
<ExclamationTriangleIcon style={{ width: 12, height: 12 }} />تداخل
</span>
)}
<button type="button" className="btn ghost sm" style={{ flexShrink: 0 }}
onClick={e => { e.stopPropagation(); addSession(day.key); }}>
<PlusIcon style={{ width: 13, height: 13 }} />
بازه
</button>
<ChevronDownIcon style={{
width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)',
transform: isExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform .2s',
}} />
</div>
{/* Expanded sessions */}
{isExpanded && (
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 16px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{sessions.length === 0 ? (
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<p className="muted" style={{ fontSize: 13, marginBottom: 10 }}>هیچ بازه‌ای برای این روز تنظیم نشده</p>
<button type="button" onClick={() => addSession(day.key)} className="btn ghost sm">
<PlusIcon style={{ width: 14, height: 14 }} />
افزودن اولین بازه
</button>
</div>
) : sessions.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
))}
</div>
)}
</div>
);
})}
<div className="flex items-center justify-between pt-3">
{(hasAnyOverlap || missingLocation) && (
<p className="text-xs text-red-500 dark:text-red-400 flex items-center gap-1">
<ExclamationTriangleIcon className="w-3.5 h-3.5" />
{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}
</p>
)}
<button type="button" onClick={() => saveMut.mutate()}
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
</button>
</div>
</div>
);
}
// ── Date Override Modal ────────────────────────────────────────────────────
function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addresses }: {
open: boolean; onClose: () => void;
existing: DateOverrideData | null; doctorUuid: string;
onSaved: () => void; addresses: AddressData[];
}) {
const [dateStr, setDateStr] = useState('');
const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed');
const [reason, setReason] = useState('');
const [slots, setSlots] = useState<SessionConfig[]>([]);
const toSession = useCallback((s: any): SessionConfig => ({
active: true,
start_time: s.start_time ?? s.start ?? '09:00',
end_time: s.end_time ?? s.end ?? '13:00',
duration_per_patient: s.duration_per_patient ?? s.duration ?? 20,
location_id: s.location_id ?? null,
has_rest: s.has_rest ?? false,
rest_interval: s.rest_interval ?? 60,
time_to_rest: s.time_to_rest ?? 10,
patient_limit: s.patient_limit ?? null,
}), []);
useEffect(() => {
if (open) {
if (existing) {
setDateStr(tsToDate(existing.date));
setOverrideType(existing.active ? 'custom' : 'closed');
setReason(existing.reason ?? '');
setSlots((existing.custom_slots ?? []).map(toSession));
} else {
setDateStr(new Date().toISOString().slice(0, 10));
setOverrideType('closed'); setReason(''); setSlots([]);
}
}
}, [open, existing, toSession]);
const saveMut = useMutation({
mutationFn: () => {
const active = overrideType === 'custom';
const body = { date: dateStr, active, reason: reason || undefined, custom_slots: active ? slots : [] };
if (existing)
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid });
},
onSuccess: () => { toast.success(existing ? 'ویرایش شد' : 'تاریخ خاص اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش تاریخ خاص' : 'افزودن تاریخ خاص'} size="md" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending || !dateStr}
className="btn primary sm">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تاریخ</label>
<PersianDateInput value={dateStr} onChange={setDateStr} placeholder="انتخاب تاریخ" />
</div>
{/* ─ نوع override */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">نوع این روز</label>
<div className="grid grid-cols-2 gap-2">
<button type="button"
onClick={() => setOverrideType('closed')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'closed' ? 'border-red-400 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300' : 'border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:border-slate-300'}`}>
<XCircleIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">تعطیل است</p>
<p className="text-xs opacity-70 mt-0.5">هیچ نوبتی داده نمی‌شود</p>
</div>
</button>
<button type="button"
onClick={() => setOverrideType('custom')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'custom' ? 'border-amber-400 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:border-slate-300'}`}>
<CalendarIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">ساعات خاص</p>
<p className="text-xs opacity-70 mt-0.5">جایگزین برنامه هفتگی</p>
</div>
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">دلیل (اختیاری)</label>
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
placeholder={overrideType === 'closed' ? 'مثال: سفر، مریضی، کنگره...' : 'مثال: شیفت اضطراری، ویزیت خاص...'}
className="input" />
</div>
{overrideType === 'custom' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>بازه‌های زمانی</label>
<button type="button" className="btn ghost sm"
onClick={() => setSlots(prev => [...prev, {
...DEFAULT_SESSION,
location_id: addresses[0] ? Number(addresses[0].id) : null,
}])}>
<PlusIcon style={{ width: 13, height: 13 }} />
افزودن بازه
</button>
</div>
{slots.length === 0 ? (
<p className="muted" style={{ fontSize: 12 }}>اگر خالی بماند از برنامه هفتگی استفاده می‌شود</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{slots.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
onChange={s => setSlots(prev => prev.map((old, i) => i === idx ? s : old))}
onRemove={() => setSlots(prev => prev.filter((_, i) => i !== idx))} />
))}
</div>
)}
</div>
)}
{overrideType === 'closed' && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30">
<ExclamationTriangleIcon className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-700 dark:text-red-400">
در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی ساعات کاری داشته باشید.
</p>
</div>
)}
</div>
</Modal>
);
}
// ── Date Overrides Tab ─────────────────────────────────────────────────────
function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DateOverrideData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-overrides', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`),
staleTime: 0,
});
const overrides: DateOverrideData[] = useMemo(
() => listQ.data?.data?.data ?? listQ.data?.data ?? [], [listQ.data]
);
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${uuid}`),
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
if (listQ.isLoading) return (
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
return (
<div className="space-y-3">
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
className="flex items-center gap-1.5 text-sm cp-btn-primary">
<PlusIcon className="w-4 h-4" />تاریخ خاص جدید
</button>
</div>
{overrides.length === 0 ? (
<div className="text-center py-10 text-slate-400 dark:text-slate-500">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هیچ تاریخ خاصی تنظیم نشده</p>
</div>
) : (
<div className="space-y-2">
{overrides.map(ov => (
<div key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800/60">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{formatPersianDate(tsToDate(ov.date))}</span>
{ov.active ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400">
تعطیل
</span>
)}
</div>
{ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{ov.reason}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => { setEditing(ov); setModalOpen(true); }}
className="w-8 h-8 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={() => setDeletingUuid(ov.uuid)}
className="w-8 h-8 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>
))}
</div>
)}
<DateOverrideModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid} addresses={addresses}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
</div>
);
}
// ── Holiday Modal ──────────────────────────────────────────────────────────
function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
open: boolean; onClose: () => void;
existing: HolidayData | null; doctorUuid: string;
onSaved: () => void;
}) {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [reason, setReason] = useState('');
useEffect(() => {
if (open) {
if (existing) {
setStartDate(tsToDate(existing.start_date));
setEndDate(tsToDate(existing.end_date));
setReason(existing.reason ?? '');
} else {
const today = new Date().toISOString().slice(0, 10);
setStartDate(today); setEndDate(today); setReason('');
}
}
}, [open, existing]);
const invalid = !startDate || !endDate || endDate < startDate;
const saveMut = useMutation({
mutationFn: () => {
const body = { start_date: startDate, end_date: endDate, reason: reason || undefined };
if (existing)
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid });
},
onSuccess: () => { toast.success(existing ? 'تعطیلات ویرایش شد' : 'تعطیلات اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات'} size="md" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending || invalid}
className="btn primary sm">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">از تاریخ</label>
<PersianDateInput value={startDate} onChange={v => { setStartDate(v); if (endDate && v > endDate) setEndDate(v); }} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تا تاریخ</label>
<PersianDateInput value={endDate} onChange={setEndDate} minDate={startDate} />
</div>
</div>
{endDate && startDate && endDate < startDate && (
<p className="text-xs text-red-500">تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد</p>
)}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">دلیل (اختیاری)</label>
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
placeholder="مثال: سفر، کنگره پزشکی..." className="input" />
</div>
</div>
</Modal>
);
}
// ── Holidays Tab ───────────────────────────────────────────────────────────
function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<HolidayData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-holidays', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`),
staleTime: 0,
});
const holidays: HolidayData[] = useMemo(
() => listQ.data?.data?.data ?? listQ.data?.data ?? [], [listQ.data]
);
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${uuid}`),
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const toggleMut = useMutation({
mutationFn: (h: HolidayData) => api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${h.uuid}`, { active: !h.active }),
onSuccess: () => { toast.success('وضعیت بروز شد'); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
if (listQ.isLoading) return (
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
return (
<div className="space-y-3">
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
className="flex items-center gap-1.5 text-sm cp-btn-primary">
<PlusIcon className="w-4 h-4" />تعطیلات جدید
</button>
</div>
{holidays.length === 0 ? (
<div className="text-center py-10 text-slate-400 dark:text-slate-500">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هیچ تعطیلاتی ثبت نشده</p>
</div>
) : (
<div className="space-y-2">
{holidays.map(h => {
const now = Math.floor(Date.now() / 1000);
const isPast = h.end_date < now;
const isCurrent = h.start_date <= now && h.end_date >= now;
const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date);
return (
<div key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-red-200 dark:border-red-500/30 bg-red-50/40 dark:bg-red-500/5' : isPast ? 'border-slate-200 dark:border-gray-700 bg-slate-50/40 dark:bg-gray-800/30 opacity-70' : 'border-orange-200 dark:border-orange-500/30 bg-orange-50/30 dark:bg-orange-500/5'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${isPast ? 'text-slate-500 dark:text-slate-400' : 'text-slate-800 dark:text-slate-200'}`}>
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
</span>
{isCurrent && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 animate-pulse">در جریان</span>
)}
{!isCurrent && !isPast && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400">آینده</span>
)}
{isPast && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-400 dark:text-slate-500">گذشته</span>
)}
{!h.active && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-400 dark:text-slate-500">غیرفعال</span>
)}
</div>
{h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{h.reason}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending}
className={`text-xs px-2.5 h-7 rounded-lg border transition-colors disabled:opacity-50 ${h.active ? 'border-slate-200 dark:border-gray-600 text-slate-500 dark:text-slate-400 hover:border-slate-300 hover:text-slate-700 dark:hover:text-slate-200' : 'border-emerald-200 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'}`}>
{h.active ? 'غیرفعال' : 'فعال'}
</button>
<button onClick={() => { setEditing(h); setModalOpen(true); }}
className="w-8 h-8 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={() => setDeletingUuid(h.uuid)}
className="w-8 h-8 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>
);
})}
</div>
)}
<HolidayModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تعطیلات" message="آیا از حذف این تعطیلات اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
</div>
);
}
// ── Schedule Section ───────────────────────────────────────────────────────
const SCHEDULE_TABS = [
{ id: 'weekly' as const, label: 'ساعات کاری هفتگی' },
{ id: 'overrides' as const, label: 'تاریخ‌های خاص' },
{ id: 'holidays' as const, label: 'تعطیلات' },
];
function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
return (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">برنامه کاری</h2>
<div className="flex gap-1 mb-5 bg-slate-100 dark:bg-gray-800 p-1 rounded-xl">
{SCHEDULE_TABS.map(t => (
<button key={t.id} type="button" onClick={() => setTab(t.id)}
className={`flex-1 text-xs sm:text-sm font-medium px-2 py-1.5 rounded-lg transition-colors ${
tab === t.id
? 'bg-white dark:bg-gray-700 text-slate-800 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
}`}>
{t.label}
</button>
))}
</div>
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={addresses} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={addresses} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
</div>
);
}
// ── Edit schema ────────────────────────────────────────────────────────────
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: z.string().max(15).optional().or(z.literal('')),
info: z.string().max(2000).optional().or(z.literal('')),
specialties: z.array(z.number()).optional(),
services: z.array(z.number()).optional(),
});
type EditForm = z.infer<typeof editSchema>;
// ── Main Page ──────────────────────────────────────────────────────────────
export default function DoctorDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const qc = useQueryClient();
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 [addrModalOpen, setAddrModalOpen] = useState(false);
const [editingAddr, setEditingAddr] = useState<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(null);
// ── Queries ──
const { data, isLoading, isError } = useQuery({
queryKey: ['doctor-detail', uuid],
queryFn: () => 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 } = 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)),
});
}
}, [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: body.gender || undefined,
degree: body.degree || undefined,
medical_system_code: body.medical_system_code || undefined,
mobile_number: body.mobile_number || undefined,
info: body.info || undefined,
specialties: body.specialties ?? [],
doctor_services: body.services ?? [],
}),
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 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] });
},
onError: (e: Error) => toast.error(e.message),
});
// ── Image upload ──
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">
<button onClick={() => navigate('/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" />پزشکان
</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={handleImageUpload} uploading={uploadingImg} />
<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">
<button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm">
<PencilIcon className="w-4 h-4" />ویرایش
</button>
{/* Toggle active button — styled by current state */}
<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>} />
</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>
<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}
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>
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline">
اولین آدرس را اضافه کنید
</button>
</div>
)}
</div>
{uuid && <ScheduleSection doctorUuid={uuid} addresses={doctor.address ?? []} />}
{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>
}
<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>
}
<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>
</div>
</div>
{/* ── Edit Modal ─────────────────────────────────────────── */}
<Modal open={editOpen} title="ویرایش اطلاعات پزشک" size="lg" onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)} className="cp-btn-secondary px-5">لغو</button>
<button form="edit-doctor-form" type="submit" disabled={updateMut.isPending} className="btn primary sm">
{updateMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</>
}
>
<form id="edit-doctor-form" onSubmit={handleSubmit(v => updateMut.mutate(v))} className="space-y-5">
<div className="space-y-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">اطلاعات پایه</p>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">نام کامل *</label>
<input type="text" className="input" {...register('name')} />
</div>
<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>
<select className="cp-select h-11" {...register('gender')}>
<option value="">انتخاب کنید</option>
{GENDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">درجه تحصیلی</label>
<select className="cp-select h-11" {...register('degree')}>
<option value="">انتخاب کنید</option>
{DEGREE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">کد نظام پزشکی</label>
<input type="text" dir="ltr" className="cp-input text-left" {...register('medical_system_code')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">شماره موبایل مطب</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="09..." {...register('mobile_number')} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بیوگرافی</label>
<textarea rows={3} className="cp-input resize-none" {...register('info')} />
</div>
</div>
<div className="space-y-2 border-t border-slate-100 dark:border-gray-700 pt-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
تخصص‌ها
{watchedSpecialties.length > 0 && (
<span className="text-primary-600 dark:text-primary-400 mr-2 normal-case font-normal">
({formatNumber(watchedSpecialties.length)} انتخاب)
</span>
)}
</p>
<HierarchicalSpecialtyPicker
selected={watchedSpecialties}
onChange={ids => setValue('specialties', ids)}
specialties={specialties}
/>
</div>
<div className="space-y-2 border-t border-slate-100 dark:border-gray-700 pt-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
خدمات
{watchedServices.length > 0 && (
<span className="text-emerald-600 dark:text-emerald-400 mr-2 normal-case font-normal">
({formatNumber(watchedServices.length)} انتخاب)
</span>
)}
</p>
<ServicesPicker
selected={watchedServices}
onChange={ids => setValue('services', ids)}
services={services}
specialties={specialties}
selectedSpecialtyIds={watchedSpecialties}
/>
</div>
</form>
</Modal>
{uuid && (
<AddressModal
open={addrModalOpen}
onClose={() => { setAddrModalOpen(false); setEditingAddr(null); }}
existing={editingAddr}
doctorUuid={uuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-detail', 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)}
/>
</div>
);
}