Returning from a resource to the doctor's own booking meant clicking the doctor tab in the row above, which nobody found. The resource strip now leads with a "نوبتهای خود پزشک" tab that clears the resource, so going in and coming back happen in the same row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1020 lines
49 KiB
TypeScript
1020 lines
49 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
|
|
AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
|
import type { Appointment, ClinicResource } from '../types';
|
|
import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils';
|
|
import PriceInput from '../components/ui/PriceInput';
|
|
import Modal from '../components/ui/Modal';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import { useClinicContext } from '../hooks/useClinicContext';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
|
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
|
|
import PersianCalendar from '../components/ui/PersianCalendar';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
// اجزای طرح نوبتهای tauri
|
|
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
|
|
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
|
import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
|
|
import DoctorTabs from '../components/appointments/DoctorTabs';
|
|
import { useResources } from '../hooks/useResources';
|
|
import ResourceBookingModal from '../components/appointments/ResourceBookingModal';
|
|
import ResourceDayPanel from '../components/appointments/ResourceDayPanel';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
|
import TurnsTable from '../components/appointments/TurnsTable';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
|
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
|
|
import type { TimelineSlot } from '../components/appointments/types';
|
|
|
|
const EMPTY_ARR: Appointment[] = [];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Date Navigator (طرح tauri — ناوبری روزانه)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const WEEK_DAYS_FA = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
|
|
|
function getPersianWeekDay(gregorianDate: string): string {
|
|
return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()];
|
|
}
|
|
|
|
const navBtnSx: React.CSSProperties = {
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-sm)', height: 36, width: 36,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', color: 'var(--text-2)',
|
|
};
|
|
|
|
function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) {
|
|
const [showCal, setShowCal] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const weekDay = getPersianWeekDay(date);
|
|
const isFriday = new Date(date + 'T12:00:00').getDay() === 5;
|
|
|
|
function addDays(n: number) {
|
|
const d = new Date(date + 'T12:00:00');
|
|
d.setDate(d.getDate() + n);
|
|
onChange(toGregorianDate(d));
|
|
}
|
|
|
|
return (
|
|
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
|
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<div style={{
|
|
padding: '0 14px', height: 44,
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-sm)', minWidth: 130, gap: 1,
|
|
}}>
|
|
<span style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.2, color: isFriday ? 'var(--danger)' : 'var(--text)' }}>
|
|
{weekDay}
|
|
</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.2 }}>
|
|
{formatDate(date)}
|
|
</span>
|
|
</div>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(-1)}>
|
|
<ChevronLeftIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => setShowCal(c => !c)}>
|
|
<CalendarDaysIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
{showCal && (
|
|
<PersianCalendar value={date} onChange={v => { onChange(v); setShowCal(false); }} onClose={() => setShowCal(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Quick booking modal (کلیک روی اسلات خالیِ زمانبندی)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; }
|
|
|
|
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
|
|
|
|
export function NewAppointmentModal({
|
|
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null,
|
|
}: {
|
|
slot: BookingSlot;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
serviceMode?: boolean;
|
|
services?: import('../hooks/useDoctorBookingServices').BookingService[];
|
|
date?: string;
|
|
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
|
clinicUuid?: string | null;
|
|
}) {
|
|
const [mobile, setMobile] = useState('');
|
|
const [lookup, setLookup] = useState<PatientLookup | null>(null);
|
|
const [patientName, setPatientName] = useState('');
|
|
const [nationalCode, setNationalCode] = useState('');
|
|
// معیار جستجوی بیمار: کد ملی (پیشفرض) یا موبایل.
|
|
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
|
|
const [pick, setPick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
|
|
|
|
const role = useAuthStore(s => s.primaryRole);
|
|
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
|
|
|
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
|
|
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ میدهد.
|
|
//
|
|
// قیمت باید از تنظیمات نوبتدهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
|
|
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر میماند. اگر دسترسی به تنظیمات آن
|
|
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمیگردیم تا فلگ الزامیبودن از دست نرود.
|
|
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
|
|
const doctorPricingQ = useQuery<Pricing>({
|
|
queryKey: ['insurance-pricing', slot.doctor_uuid],
|
|
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
|
|
retry: false,
|
|
});
|
|
const selfPricingQ = useQuery<Pricing>({
|
|
queryKey: ['insurance-pricing'],
|
|
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
|
enabled: doctorPricingQ.isError,
|
|
});
|
|
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
|
|
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
|
|
const requireVisit = pricing?.data?.require_visit_price ?? false;
|
|
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
|
|
const [visitPriceToman, setVisitPriceToman] = useState(0);
|
|
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
|
|
useEffect(() => {
|
|
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
|
|
}, [freeVisit, visitPriceTouched]);
|
|
// هزینه ویزیت اختیاری داخل کلپسِ بسته مینشیند؛ وقتی الزامی است کلپس همیشه باز است.
|
|
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
|
|
const visitPriceExpanded = requireVisit || visitPriceOpen;
|
|
|
|
const mobileValid = /^09\d{9}$/.test(mobile);
|
|
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
|
|
// اعتبار کلید جستجو بسته به معیار انتخابشده.
|
|
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
|
|
const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot);
|
|
// یک بیمارِ یافتشده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
|
|
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
|
|
const needsDetails = lookup !== null && !foundWithNationalCode; // یافتنشده، یا یافتشده بدون کد ملی
|
|
|
|
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
|
|
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
|
|
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
|
|
const visitPriceValid = !requireVisit || visitPriceToman > 0;
|
|
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
|
|
|
|
const search = useMutation({
|
|
mutationFn: () => {
|
|
const q = searchBy === 'mobile'
|
|
? `mobile=${encodeURIComponent(mobile)}`
|
|
: `national_code=${encodeURIComponent(nationalCode)}`;
|
|
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
|
|
},
|
|
onSuccess: (res: any) => {
|
|
const data: PatientLookup = res?.data ?? { found: false };
|
|
setLookup(data);
|
|
setPatientName(data.found ? (data.name ?? '') : '');
|
|
// موبایل و کد ملیِ بیمارِ یافتشده را پر میکنیم تا ثبت مستقل از معیار جستجو کار کند.
|
|
if (data.found) {
|
|
if (data.mobile) setMobile(data.mobile);
|
|
setNationalCode(data.national_code ?? '');
|
|
} else if (searchBy === 'mobile') {
|
|
setNationalCode('');
|
|
}
|
|
},
|
|
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
|
|
});
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: () => api.post(createEndpoint, {
|
|
doctor_uuid: slot.doctor_uuid,
|
|
slot_start: serviceMode ? pick.slot!.start : slot.start,
|
|
slot_end: serviceMode ? pick.slot!.end : slot.end,
|
|
patient_mobile: mobile,
|
|
patient_name: effectiveName,
|
|
patient_national_code: effectiveNationalCode,
|
|
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
|
|
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
|
|
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('نوبت با موفقیت ثبت شد');
|
|
onSuccess();
|
|
onClose();
|
|
},
|
|
onError: (e: any) => {
|
|
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
|
|
toast.error(msg);
|
|
},
|
|
});
|
|
|
|
// تغییر کلید جستجو نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
|
function invalidateLookup() {
|
|
if (lookup !== null) { setLookup(null); setPatientName(''); }
|
|
}
|
|
function onMobileChange(v: string) {
|
|
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
|
setMobile(sanitizeMobileInput(v));
|
|
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
|
|
}
|
|
function onNationalSearchChange(v: string) {
|
|
setNationalCode(digitsOnly(v, 10));
|
|
invalidateLookup();
|
|
}
|
|
// جابهجایی معیار جستجو همهچیز را از نو شروع میکند.
|
|
function onSwitchSearchBy(mode: 'mobile' | 'national') {
|
|
setSearchBy(mode);
|
|
setLookup(null); setPatientName('');
|
|
setMobile(''); setNationalCode('');
|
|
}
|
|
|
|
const priceHint = pricingLoading
|
|
? 'در حال خواندن تعرفهٔ پزشک…'
|
|
: freeVisit > 0
|
|
? `تعرفهٔ نوبتدهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
|
|
: 'برای این پزشک تعرفهای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
|
|
|
|
return (
|
|
<Modal
|
|
open
|
|
title="ثبت نوبت"
|
|
size="sm"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button className="btn ghost" onClick={onClose}>انصراف</button>
|
|
<button
|
|
className="btn primary"
|
|
onClick={() => mutation.mutate()}
|
|
disabled={!isValid || mutation.isPending}
|
|
>
|
|
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
{/* اسلات انتخابشده */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
|
|
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
|
}}>
|
|
<ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
|
|
{serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
|
|
</span>
|
|
{!serviceMode && (
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
|
|
{slot.doctor_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{serviceMode && date && (
|
|
<div style={{ marginBottom: 18 }}>
|
|
<ServiceSlotPicker
|
|
doctorUuid={slot.doctor_uuid}
|
|
date={date}
|
|
services={services}
|
|
onSelect={setPick}
|
|
clinicUuidOverride={clinicUuid}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="field-block" style={{ marginBottom: 14 }}>
|
|
<label>جستجوی بیمار <span className="req">*</span></label>
|
|
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
|
{(['national', 'mobile'] as const).map(mode => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
|
|
onClick={() => onSwitchSearchBy(mode)}
|
|
style={{ flex: 1 }}
|
|
>
|
|
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div className="field" style={{ flex: 1 }}>
|
|
{searchBy === 'mobile' ? (
|
|
<input
|
|
type="tel"
|
|
inputMode="numeric"
|
|
maxLength={11}
|
|
value={mobile}
|
|
onChange={e => onMobileChange(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
|
placeholder="مثال: 09123456789"
|
|
style={{ direction: 'ltr' }}
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
lang="en"
|
|
maxLength={10}
|
|
value={nationalCode}
|
|
onChange={e => onNationalSearchChange(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
|
placeholder="کد ملی ۱۰ رقمی"
|
|
style={{ direction: 'ltr' }}
|
|
autoFocus
|
|
/>
|
|
)}
|
|
</div>
|
|
<button
|
|
className="btn soft"
|
|
onClick={() => search.mutate()}
|
|
disabled={!searchValid || search.isPending}
|
|
style={{ whiteSpace: 'nowrap' }}
|
|
>
|
|
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
|
|
{search.isPending ? '...' : 'جستجو'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{foundWithNationalCode && (
|
|
<div style={{
|
|
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
|
background: 'var(--success-bg)', fontSize: 13,
|
|
display: 'flex', alignItems: 'center', gap: 10,
|
|
}}>
|
|
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
|
|
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
|
|
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{needsDetails && (
|
|
<>
|
|
<div style={{
|
|
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
|
|
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
|
|
}}>
|
|
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
|
|
</div>
|
|
<div className="field-block" style={{ marginBottom: 14 }}>
|
|
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
|
|
<div className="field">
|
|
<input
|
|
type="text"
|
|
value={patientName}
|
|
onChange={e => setPatientName(e.target.value)}
|
|
placeholder="مثال: علی محمدی"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
|
|
{searchBy === 'national' && (
|
|
<div className="field-block" style={{ marginBottom: 14 }}>
|
|
<label>شماره موبایل بیمار <span className="req">*</span></label>
|
|
<div className="field">
|
|
<input
|
|
type="tel"
|
|
inputMode="numeric"
|
|
maxLength={11}
|
|
value={mobile}
|
|
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
|
|
placeholder="مثال: 09123456789"
|
|
style={{ direction: 'ltr' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده میشود و فیلد تکراری لازم نیست. */}
|
|
{searchBy === 'mobile' && (
|
|
<div className="field-block" style={{ marginBottom: 14 }}>
|
|
<label>کد ملی بیمار <span className="req">*</span></label>
|
|
<div className="field">
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
lang="en"
|
|
maxLength={10}
|
|
value={nationalCode}
|
|
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
|
placeholder="کد ملی ۱۰ رقمی"
|
|
style={{ direction: 'ltr' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="field-block">
|
|
{requireVisit ? (
|
|
<label>
|
|
هزینه ویزیت (تومان)<span className="req"> *</span>
|
|
</label>
|
|
) : (
|
|
// سرِ کلپس — با کلیک باز/بسته میشود (فقط وقتی اختیاری است).
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisitPriceOpen(o => !o)}
|
|
aria-expanded={visitPriceExpanded}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
|
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
|
|
padding: 0, color: 'var(--text)',
|
|
}}
|
|
>
|
|
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
|
|
<ChevronDownIcon
|
|
style={{
|
|
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
|
|
transition: 'transform .2s var(--ease)',
|
|
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
|
|
}}
|
|
/>
|
|
</button>
|
|
)}
|
|
{visitPriceExpanded && (
|
|
<>
|
|
<div
|
|
className="field"
|
|
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
|
|
>
|
|
<PriceInput
|
|
value={visitPriceToman}
|
|
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
|
suffix="تومان"
|
|
/>
|
|
</div>
|
|
{requireVisit && visitPriceToman <= 0
|
|
? <span className="field-err">هزینه ویزیت الزامی است</span>
|
|
: <span className="field-hint">{priceHint}</span>}
|
|
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
|
|
<button
|
|
type="button"
|
|
className="btn ghost sm"
|
|
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
|
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
|
|
>
|
|
استفاده از تعرفهٔ پزشک
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Main Page — «نوبت ها» (طرح tauri)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; }
|
|
|
|
export default function AppointmentsPage() {
|
|
const navigate = useNavigate();
|
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
|
const dbUuid = useAuthStore(s => s.dbUuid);
|
|
// در محیط کلینیک، dbUuid شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از doctorUuid میآید.
|
|
const doctorUuid = useAuthStore(s => s.doctorUuid);
|
|
const clinicUuid = useClinicContext();
|
|
const scope = useAuthStore(s => s.context?.scope);
|
|
const isAdmin = primaryRole === 'admin';
|
|
const isClinic = primaryRole === 'clinic';
|
|
const isDoctor = primaryRole === 'doctor';
|
|
const isRepresentation = primaryRole === 'representation';
|
|
// منشی در هر دو ساختار باید تایملاین ببیند: کلینیک چندپزشکه (تب پزشکانِ
|
|
// تخصیصیافته) و پزشک مستقل (همان یک پزشک). لیست در هر دو حالت از اندپوینتِ
|
|
// احرازشدهٔ /my/clinic-doctors میآید که خودش هر دو سناریو را resolve میکند.
|
|
const isSecretary = primaryRole === 'secretary';
|
|
const isClinicScopedSecretary = isSecretary && scope === 'clinic';
|
|
// مجوزهای منشی روی نوبتها؛ برای owner/پزشک همیشه true.
|
|
const { can } = usePermissions();
|
|
const canCreateAppt = can('appointments', 'create');
|
|
const canManageAppt = can('appointments', 'update_status');
|
|
const canCancelAppt = can('appointments', 'cancel');
|
|
|
|
const [params] = useSearchParams();
|
|
const today = todayIso();
|
|
// پس از ویرایش/ثبت، صفحه با ?date=... باز میشود تا همان روز نمایش داده شود.
|
|
const [selectedDate, setSelectedDate] = useState(params.get('date') || today);
|
|
const [viewMode, setViewMode] = useState<TurnsViewMode>('timeline');
|
|
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor ? (doctorUuid ?? '') : '');
|
|
|
|
/**
|
|
* تب منبع در URL مینشیند تا «بازگشت» و رفرش همان تب را برگردانند — همان قاعدهای که
|
|
* `CLAUDE.md` برای وضعیت لیستها میگذارد. (تب پزشک هنوز `useState` است؛ رفعش
|
|
* refactor جداست و اینجا دست نمیخورد.)
|
|
*/
|
|
const [urlState, setUrlState] = useUrlState({ resource: '' });
|
|
const selectedResourceUuid = urlState.resource;
|
|
const { resources: bookableResources } = useResources({ active: '1' });
|
|
|
|
/**
|
|
* منابعی که پزشکِ انتخابشده ناظرشان است.
|
|
*
|
|
* وقتی خودِ تب منبع فعال است `selectedDoctorUuid` خالی میشود، پس ناظرِ همان منبع
|
|
* مبنا قرار میگیرد — وگرنه نوار منابع زیر پای کاربر خالی میشد.
|
|
*/
|
|
const supervisedResources = selectedDoctorUuid
|
|
? bookableResources.filter((r) => r.supervisor?.uuid === selectedDoctorUuid)
|
|
: [];
|
|
|
|
const [bookingResource, setBookingResource] = useState<ClinicResource | null>(null);
|
|
const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null;
|
|
|
|
/**
|
|
* منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک
|
|
* تنگ میکند. پاک کردن پزشک، نوبتدهی خودِ او را از دسترس خارج میکرد.
|
|
*/
|
|
const selectResource = (uuid: string) => setUrlState({ resource: uuid });
|
|
|
|
/** برگشت به خودِ پزشک: نمای منبع بسته میشود. */
|
|
const selectDoctor = (uuid: string) => {
|
|
setSelectedDoctorUuid(uuid);
|
|
if (selectedResourceUuid) setUrlState({ resource: '' });
|
|
};
|
|
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
|
|
const qc = useQueryClient();
|
|
|
|
// ── Today stats
|
|
const statsQuery = useQuery<ApiResponse<TodayStats>>({
|
|
queryKey: ['appt-today-stats', selectedDate, isAdmin],
|
|
queryFn: () => api.get(
|
|
isAdmin
|
|
? `/api/v1/admin/appointments/today-stats?date=${selectedDate}`
|
|
: `/api/v1/my/appointments/today-stats?date=${selectedDate}`
|
|
),
|
|
});
|
|
const stats = statsQuery.data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 };
|
|
|
|
// ── Appointments query
|
|
const apptEndpoint = isAdmin
|
|
? '/api/v1/admin/appointments'
|
|
: isRepresentation
|
|
? '/api/v1/representation/appointments'
|
|
: '/api/v1/my/appointments';
|
|
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid, selectedResourceUuid];
|
|
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
|
|
// تب منبع جای تب پزشک را میگیرد، نه اینکه رویش سوار شود: «نوبتهای لیزر CO2» یعنی
|
|
// همهٔ نوبتهای آن دستگاه، از هر پزشکی.
|
|
if (selectedResourceUuid) apptParams.set('resource_uuid', selectedResourceUuid);
|
|
else if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
|
|
|
|
const apptQuery = useQuery<PaginatedResponse<Appointment>>({
|
|
queryKey: apptQueryKey,
|
|
queryFn: () => api.get(`${apptEndpoint}?${apptParams}`),
|
|
});
|
|
const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR;
|
|
const filteredAppointments = applyAppointmentFilters(appointments, filters);
|
|
const filtersActive = filters !== EMPTY_FILTERS && JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS);
|
|
|
|
// Table pagination — client-side (full day stays loaded for timeline + doctor tabs).
|
|
const TABLE_PAGE_SIZE = 20;
|
|
const [tablePage, setTablePage] = useState(1);
|
|
useEffect(() => { setTablePage(1); }, [selectedDate, selectedDoctorUuid, filters]);
|
|
const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE);
|
|
|
|
// ── Clinic doctors (authoritative list for tabs)
|
|
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string; has_schedule?: boolean }[] }>>({
|
|
queryKey: ['clinic-doctors', dbUuid, isSecretary, isClinic],
|
|
// منشی و کلینیک از اندپوینتِ احرازشده میگیرند: هم فقط پزشکانِ مجاز را میدهد و هم
|
|
// `has_schedule` را، که مبنای ساختن تب است. ادمین از لیستِ کلینیکِ انتخابشده
|
|
// میخواند (آن اندپوینت نقش ادمین را پوشش نمیدهد) و آنجا فلگ نمیآید.
|
|
queryFn: () => api.get(
|
|
isSecretary || isClinic
|
|
? '/api/v1/my/clinic-doctors'
|
|
: `/api/v1/clinic/doctor-list/${dbUuid}`,
|
|
),
|
|
enabled: isSecretary || (isClinic && !!dbUuid),
|
|
});
|
|
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
|
|
|
const doctors = React.useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
// پزشکِ بدون برنامهٔ کاری تب نمیگیرد — تبش جز یک تایملاین همیشهخالی چیزی ندارد.
|
|
// نبودِ فیلد (مسیر ادمین) یعنی «نمیدانیم»، پس پنهان نمیشود.
|
|
clinicDoctorsList
|
|
.filter(d => d.has_schedule !== false)
|
|
.forEach(d => map.set(d.uuid, d.name));
|
|
appointments.forEach(a => {
|
|
if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) {
|
|
map.set(a.doctor_uuid, a.doctor_name);
|
|
}
|
|
});
|
|
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
|
}, [appointments, clinicDoctorsList]);
|
|
|
|
// سرویسهای موجود در نوبتهای امروز (برای فیلتر «سرویس مورد نظر...»).
|
|
const serviceOptions = React.useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
appointments.forEach(a => {
|
|
if (a.service_item?.uuid && a.service_item?.name) map.set(a.service_item.uuid, a.service_item.name);
|
|
});
|
|
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
|
}, [appointments]);
|
|
|
|
// نوع پروفایل: کلینیک چندپزشکه (تب دکترها + مدیریت چند پزشک) در برابر پزشک مستقل.
|
|
// نقش clinic/admin = چندپزشکه؛ نقش doctor (حتی مهمانِ کلینیک) = مستقل، فقط برنامهٔ خودش.
|
|
const isMultiDoctorClinic = isClinic || isAdmin || isSecretary;
|
|
const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1;
|
|
const showDoctorCol = isAdmin && !selectedDoctorUuid;
|
|
|
|
// کلینیک و منشی: اولین پزشکِ در دسترس پیشفرض انتخاب میشود تا تایملاین خالی نماند.
|
|
// برای منشیِ پزشک مستقل این تنها راه انتخاب است (تبِ پزشک هم یک گزینه بیشتر ندارد).
|
|
useEffect(() => {
|
|
if ((isClinic || isSecretary) && !selectedDoctorUuid && doctors.length > 0) {
|
|
setSelectedDoctorUuid(doctors[0].uuid);
|
|
}
|
|
}, [isClinic, isSecretary, selectedDoctorUuid, doctors]);
|
|
|
|
// ── محل نوبتدهی برای ادمین
|
|
// ادمین context کلینیکی ندارد (useClinicContext → null)؛ بدون clinic_uuid فقط برنامهٔ
|
|
// مطب شخصی خوانده میشود. محل از booking-locations همان پزشک انتخاب میشود.
|
|
const adminLocationsQuery = useQuery<ApiResponse<any>>({
|
|
queryKey: ['booking-locations', selectedDoctorUuid],
|
|
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}?management=1`),
|
|
enabled: isAdmin && !!selectedDoctorUuid,
|
|
});
|
|
const adminLocations: any[] = (adminLocationsQuery.data?.data as any)?.booking_locations ?? EMPTY_ARR;
|
|
const [adminLocKey, setAdminLocKey] = useState<string | null>(null);
|
|
useEffect(() => { setAdminLocKey(null); }, [selectedDoctorUuid]);
|
|
const locKey = (l: any) => l.clinic_uuid ?? 'personal';
|
|
// پیشفرض = اولین آیتم؛ backend بر اساس زودترین نوبت آزاد مرتب کرده است.
|
|
const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null;
|
|
const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid;
|
|
|
|
// ── Slots query (timeline)
|
|
// effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده میشود.
|
|
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid];
|
|
const slotsQuery = useQuery<ApiResponse<any>>({
|
|
queryKey: slotsQueryKey,
|
|
queryFn: () => api.get(
|
|
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}&management=1` +
|
|
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
|
|
),
|
|
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
|
|
});
|
|
|
|
// ── روش نوبتدهی پزشکِ انتخابشده (سرویسی/اسلاتی)
|
|
const { bookingMode, services } = useDoctorBookingServices(
|
|
selectedDoctorUuid,
|
|
isAdmin ? (adminLocation?.clinic_uuid ?? null) : undefined,
|
|
);
|
|
const serviceMode = bookingMode === 'service';
|
|
|
|
// بازهٔ کاری پزشک در این روز (برای هدرِ تایملاینِ سرویسی).
|
|
const workingRange = React.useMemo(() => {
|
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
|
if (!rawSessions.length) return null;
|
|
const starts = rawSessions.map(s => s.start_time).filter(Boolean).sort();
|
|
const ends = rawSessions.map(s => s.end_time).filter(Boolean).sort();
|
|
if (!starts.length || !ends.length) return null;
|
|
return { start: starts[0], end: ends[ends.length - 1] };
|
|
}, [slotsQuery.data]);
|
|
|
|
// ── Merge sessions + appointments → flat timeline slots
|
|
const timelineSlots: TimelineSlot[] = React.useMemo(() => {
|
|
if (viewMode !== 'timeline') return [];
|
|
|
|
const activeByStart = new Map<number, Appointment>();
|
|
const cancelledByStart = new Map<number, Appointment>();
|
|
appointments.forEach(a => {
|
|
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
|
if (CANCELLED_STATUSES.has(a.status)) {
|
|
const existing = cancelledByStart.get(key);
|
|
if (!existing || a.created_at > existing.created_at) cancelledByStart.set(key, a);
|
|
} else {
|
|
activeByStart.set(key, a);
|
|
}
|
|
});
|
|
|
|
// حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبتهای رزروشده
|
|
// نمایش داده میشوند و باقیِ زمان بهصورت بازه(های) خالیِ قابلرزرو بین آنها.
|
|
if (serviceMode) {
|
|
const fmt = (ts: number) => formatTime(ts);
|
|
const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); };
|
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
|
const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start));
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const out: TimelineSlot[] = [];
|
|
// بازهٔ خالیِ قابلرزرو؛ اگر روی «اکنون» بیفتد به اکنون بریده میشود و بازههای
|
|
// کاملاً گذشته حذف میشوند تا فقط زمان قابلرزرو باقی بماند.
|
|
const pushFree = (start: number, end: number) => {
|
|
const s = start < now ? now : start;
|
|
if (end <= s) return;
|
|
out.push({ start: s, end, start_time: fmt(s), end_time: fmt(end), is_available: true, appointment: null, cancelled_appointment: null });
|
|
};
|
|
|
|
rawSessions.forEach((session: any) => {
|
|
const slots = (session.slots as any[]) ?? [];
|
|
if (!slots.length) return;
|
|
const dayStart = Number(slots[0].start) - parseHM(session.start_time);
|
|
const winStart = dayStart + parseHM(session.start_time);
|
|
const winEnd = dayStart + parseHM(session.end_time);
|
|
|
|
const inWin = booked.filter(a => Number(a.slot_start) >= winStart && Number(a.slot_start) < winEnd);
|
|
let cursor = winStart;
|
|
inWin.forEach(a => {
|
|
const s = Number(a.slot_start), e = Number(a.slot_end);
|
|
if (s > cursor) pushFree(cursor, s);
|
|
out.push({
|
|
start: s, end: e, start_time: fmt(s), end_time: fmt(e),
|
|
is_available: false, appointment: a, cancelled_appointment: null,
|
|
});
|
|
cursor = Math.max(cursor, e);
|
|
});
|
|
if (cursor < winEnd) pushFree(cursor, winEnd);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
|
const out: TimelineSlot[] = [];
|
|
rawSessions.forEach((session: any) => {
|
|
(session.slots as any[]).forEach((s: any) => {
|
|
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
|
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
|
out.push({
|
|
start: slotStart,
|
|
end: slotEnd,
|
|
start_time: s.start_time ?? formatTime(slotStart),
|
|
end_time: s.end_time ?? formatTime(slotEnd),
|
|
is_available: s.is_available as boolean,
|
|
appointment: activeByStart.get(slotStart) ?? null,
|
|
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
|
});
|
|
});
|
|
});
|
|
return out;
|
|
}, [viewMode, slotsQuery.data, appointments, serviceMode]);
|
|
|
|
// ── Slot click → quick booking modal
|
|
function handleSlotClick(slot: TimelineSlot) {
|
|
if (isRepresentation || !canCreateAppt) return;
|
|
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
|
setBookingSlot({
|
|
start: slot.start,
|
|
end: slot.end,
|
|
start_time: slot.start_time,
|
|
end_time: slot.end_time,
|
|
doctor_uuid: selectedDoctorUuid,
|
|
doctor_name: doctorName,
|
|
});
|
|
}
|
|
|
|
function openDetail(a: Appointment) {
|
|
navigate(`/admin/appointments/${a.uuid}`);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '20px 24px' }}>
|
|
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
|
{/* عنوان */}
|
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
|
|
|
{/* نوار آمار */}
|
|
<TurnsStatInfo stats={stats} />
|
|
|
|
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
|
|
}}>
|
|
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
|
|
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
|
|
|
<ServiceFilterSelect
|
|
value={filters.itemUuid}
|
|
options={serviceOptions}
|
|
onChange={(v) => setFilters(f => ({ ...f, itemUuid: v }))}
|
|
/>
|
|
|
|
{isAdmin && adminLocations.length > 1 && (
|
|
<div style={{ minWidth: 220 }}>
|
|
<SearchableSelect
|
|
options={adminLocations.map((l: any) => ({
|
|
value: locKey(l),
|
|
label: l.type === 'personal' ? `مطب شخصی${l.title ? ` — ${l.title}` : ''}` : l.title,
|
|
}))}
|
|
value={adminLocation ? locKey(adminLocation) : null}
|
|
onChange={v => setAdminLocKey(v ? String(v) : null)}
|
|
placeholder="محل نوبتدهی..."
|
|
height={44}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
|
<button
|
|
aria-label="فیلترها"
|
|
className="btn sm"
|
|
onClick={() => setFiltersOpen(true)}
|
|
style={{
|
|
border: `1px solid ${filtersActive ? 'var(--primary)' : 'var(--border)'}`,
|
|
color: filtersActive ? 'var(--primary)' : 'var(--text-2)',
|
|
background: 'var(--surface)',
|
|
}}
|
|
>
|
|
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
|
</button>
|
|
|
|
{/* رزرو منبعمحور مسیر جداست چون جستجویش از تقاطع تقویم منابع میآید، نه از
|
|
اسلاتهای یک پزشک؛ ادغامشان در یک فرم، هر دو را گیج میکرد. */}
|
|
{!isRepresentation && canCreateAppt && (
|
|
<button
|
|
className="btn secondary sm"
|
|
onClick={() => navigate('/admin/resource-booking')}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
|
>
|
|
نوبت منبعمحور
|
|
</button>
|
|
)}
|
|
|
|
{!isRepresentation && canCreateAppt && (
|
|
<button
|
|
className="btn primary sm"
|
|
onClick={() => {
|
|
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویسهای خودش.
|
|
if (activeResource) { setBookingResource(activeResource); return; }
|
|
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
|
|
navigate(`/admin/appointments/new${q}`);
|
|
}}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
|
>
|
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
|
افزودن نوبت
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
|
<div style={{
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)', overflow: 'hidden',
|
|
}}>
|
|
{showDoctorTabs && (
|
|
<DoctorTabs
|
|
doctors={doctors}
|
|
selected={selectedDoctorUuid}
|
|
onSelect={selectDoctor}
|
|
showAll={isAdmin}
|
|
/>
|
|
)}
|
|
|
|
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
|
|
(پزشک ناظر)، پس کاربر بین نوبتهای پزشک و دستگاههای تحت نظرش جابهجا میشود. */}
|
|
{supervisedResources.length > 0 && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>نوبتدهی</span>
|
|
{/* تب نخست راهِ بازگشت است: بدون آن، برگشتن از منبع به خودِ پزشک فقط با
|
|
کلیک روی تب بالایی ممکن بود و کاربر دنبالش میگشت. */}
|
|
<DoctorTabs
|
|
doctors={[
|
|
{ uuid: '', name: 'نوبتهای خود پزشک' },
|
|
...supervisedResources.map((r) => ({ uuid: r.uuid, name: r.name })),
|
|
]}
|
|
selected={selectedResourceUuid}
|
|
onSelect={selectResource}
|
|
showAll={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div style={{ padding: 16 }}>
|
|
{viewMode === 'table' ? (
|
|
<>
|
|
<TurnsTable
|
|
items={pagedAppointments}
|
|
loading={apptQuery.isLoading}
|
|
queryKey={apptQueryKey}
|
|
showDoctor={showDoctorCol}
|
|
canManage={canManageAppt}
|
|
canCancel={canCancelAppt}
|
|
/>
|
|
{filteredAppointments.length > TABLE_PAGE_SIZE && (
|
|
<div style={{ marginTop: 14 }}>
|
|
<Pagination page={tablePage} total={filteredAppointments.length} limit={TABLE_PAGE_SIZE} onPageChange={setTablePage} />
|
|
</div>
|
|
)}
|
|
</>
|
|
) : activeResource ? (
|
|
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایملاین اسلاتیِ پزشک اینجا
|
|
اصلاً رندر نمیشود — فهرست نوبتهای همین منبع و یک ورودیِ سرویسی. */
|
|
<ResourceDayPanel
|
|
resource={activeResource}
|
|
appointments={filteredAppointments}
|
|
loading={apptQuery.isLoading}
|
|
canCreate={!isRepresentation && canCreateAppt}
|
|
onBook={() => setBookingResource(activeResource)}
|
|
onView={openDetail}
|
|
/>
|
|
) : (
|
|
<>
|
|
{!selectedDoctorUuid ? (
|
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
|
|
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
|
|
</div>
|
|
) : (
|
|
<>
|
|
{serviceMode && (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
|
padding: '8px 12px', marginBottom: 12, borderRadius: 'var(--r-sm)',
|
|
background: 'var(--surface-2)', border: '1px solid var(--border)', fontSize: 12.5, color: 'var(--text-2)',
|
|
}}>
|
|
<span>نوبتدهی سرویسی — نوبتها بر اساس مدت سرویس چیده میشوند.</span>
|
|
{workingRange && (
|
|
<span dir="ltr" style={{ color: 'var(--text-3)' }}>ساعت کاری: {workingRange.start} - {workingRange.end}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<TurnsTimeline
|
|
slots={timelineSlots}
|
|
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
|
queryKey={apptQueryKey}
|
|
onView={openDetail}
|
|
onBook={handleSlotClick}
|
|
emptyReason={(slotsQuery.data?.data as any)?.empty_reason ?? null}
|
|
errorMessage={slotsQuery.isError ? ((slotsQuery.error as Error)?.message || 'خطای نامشخص') : null}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{bookingResource && (
|
|
<ResourceBookingModal
|
|
resource={bookingResource}
|
|
onClose={() => setBookingResource(null)}
|
|
onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })}
|
|
/>
|
|
)}
|
|
|
|
{/* مودال ثبت سریع نوبت */}
|
|
{bookingSlot && (
|
|
<NewAppointmentModal
|
|
slot={bookingSlot}
|
|
serviceMode={serviceMode}
|
|
services={services}
|
|
date={selectedDate}
|
|
clinicUuid={effectiveClinicUuid}
|
|
onClose={() => setBookingSlot(null)}
|
|
onSuccess={() => {
|
|
qc.invalidateQueries({ queryKey: apptQueryKey });
|
|
qc.invalidateQueries({ queryKey: slotsQueryKey });
|
|
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* مودال فیلترها */}
|
|
{filtersOpen && (
|
|
<AppointmentFiltersModal value={filters} onApply={setFilters} onClose={() => setFiltersOpen(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** فیلتر سرویس نوار ابزار — ردیفهای بارگذاریشدهٔ روز را بر اساس سرویس فیلتر میکند. */
|
|
function ServiceFilterSelect({ value, options, onChange }: {
|
|
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<div style={{ minWidth: 280 }}>
|
|
<SearchableSelect
|
|
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
|
value={value || null}
|
|
onChange={v => onChange(v ? String(v) : '')}
|
|
placeholder="سرویس مورد نظر را انتخاب کنید..."
|
|
isClearable
|
|
height={44}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|