2333 lines
114 KiB
TypeScript
2333 lines
114 KiB
TypeScript
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='© <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>
|
||
);
|
||
}
|