Two faults, one root: the per-context booking work updated ScheduleSection but left the rest of the panel calling slot endpoints without clinic_uuid. Absent clinic_uuid means the personal practice, so the panel asked about a schedule the doctor barely uses and got nothing back. - useClinicContext() resolves the current environment once and is used by the appointments page, useDoctorBookingServices, ServiceSlotPicker and both queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It returns null in a doctor's personal environment so the mirror-image bug — a doctor seeing the clinic's schedule at their own practice — cannot appear. clinicUuid is part of every query key; without it the cache leaks across environments. - appointment-slots returns empty_reason (no_schedule | holiday | day_off | outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day, which is what the bug report actually saw; it now says which of the four it is. - booking-locations lists a location only when the context has an address and an active shift points at it. The dev data had three "personal" schedules whose shifts referenced the clinic's address, so the public site advertised a personal practice that could never be booked. - ?date= adds available_on_date per location, validated as a real calendar date. - MyAppointmentsController and AdminApiController resolved the appointment address with no context and could store the wrong one. Both now go through the new BookingContextResolver, which also replaces AppointmentController's private copy of the same membership check. - app:schedule:audit-locations reports shifts pointing at a missing or foreign address; --fix deactivates them rather than deleting. Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions, with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu. Suite: 417 tests, 2 failures — both pre-existing and unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
700 lines
32 KiB
TypeScript
700 lines
32 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, CalendarDaysIcon,
|
|
AdjustmentsHorizontalIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
|
import type { Appointment } from '../types';
|
|
import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
|
|
import PriceInput from '../components/ui/PriceInput';
|
|
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 TurnsTimeline from '../components/appointments/TurnsTimeline';
|
|
import TurnsTable from '../components/appointments/TurnsTable';
|
|
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 ? '#ef4444' : '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,
|
|
}: {
|
|
slot: BookingSlot;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
serviceMode?: boolean;
|
|
services?: import('../hooks/useDoctorBookingServices').BookingService[];
|
|
date?: string;
|
|
}) {
|
|
const [mobile, setMobile] = useState('');
|
|
const [lookup, setLookup] = useState<PatientLookup | null>(null);
|
|
const [patientName, setPatientName] = useState('');
|
|
const [nationalCode, setNationalCode] = useState('');
|
|
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 خطای ۴۲۲ میدهد.
|
|
const pricingQ = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
|
|
queryKey: ['insurance-pricing'],
|
|
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
|
});
|
|
const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0;
|
|
const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false;
|
|
const [visitPriceToman, setVisitPriceToman] = useState(0);
|
|
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
|
|
useEffect(() => {
|
|
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
|
|
}, [freeVisit, visitPriceTouched]);
|
|
|
|
const mobileValid = /^09\d{9}$/.test(mobile);
|
|
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: () => api.get(`/api/v1/my/appointment/patient-lookup?mobile=${encodeURIComponent(mobile)}`),
|
|
onSuccess: (res: any) => {
|
|
const data: PatientLookup = res?.data ?? { found: false };
|
|
setLookup(data);
|
|
setPatientName(data.found ? (data.name ?? '') : '');
|
|
setNationalCode(data.found ? (data.national_code ?? '') : '');
|
|
},
|
|
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,
|
|
...(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);
|
|
},
|
|
});
|
|
|
|
const inputSx: React.CSSProperties = {
|
|
width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)',
|
|
border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13,
|
|
boxSizing: 'border-box',
|
|
};
|
|
const labelSx: React.CSSProperties = {
|
|
display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6,
|
|
};
|
|
|
|
// تغییر موبایل نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
|
function onMobileChange(v: string) {
|
|
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
|
setMobile(sanitizeMobileInput(v));
|
|
if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); }
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 500,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}} onClick={onClose}>
|
|
<div style={{
|
|
background: 'var(--surface)', borderRadius: 'var(--r)', padding: 24,
|
|
minWidth: 320, maxWidth: 400, width: '90vw', boxShadow: 'var(--shadow-lg)',
|
|
}} onClick={e => e.stopPropagation()}>
|
|
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>ثبت نوبت</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
|
|
{serviceMode
|
|
? slot.doctor_name
|
|
: `${slot.start_time} تا ${slot.end_time} — ${slot.doctor_name}`}
|
|
</div>
|
|
|
|
{serviceMode && date && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<ServiceSlotPicker
|
|
doctorUuid={slot.doctor_uuid}
|
|
date={date}
|
|
services={services}
|
|
onSelect={setPick}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginBottom: 12 }}>
|
|
<label style={labelSx}>شماره موبایل بیمار *</label>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<input
|
|
type="tel"
|
|
inputMode="numeric"
|
|
maxLength={11}
|
|
value={mobile}
|
|
onChange={e => onMobileChange(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter' && mobileValid && !search.isPending) search.mutate(); }}
|
|
placeholder="مثال: 09123456789"
|
|
style={{ ...inputSx, direction: 'ltr' }}
|
|
autoFocus
|
|
/>
|
|
<button
|
|
className="btn sm"
|
|
onClick={() => search.mutate()}
|
|
disabled={!mobileValid || search.isPending}
|
|
style={{ whiteSpace: 'nowrap' }}
|
|
>
|
|
{search.isPending ? '...' : 'جستجو'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{foundWithNationalCode && (
|
|
<div style={{
|
|
marginBottom: 16, padding: '10px 12px', borderRadius: 'var(--r-sm)',
|
|
background: 'var(--success-bg)', border: '1px solid var(--success)', fontSize: 13,
|
|
}}>
|
|
<div style={{ fontWeight: 700, color: 'var(--success)', marginBottom: 2 }}>بیمار یافت شد</div>
|
|
<div style={{ color: 'var(--text)' }}>{lookup?.name}</div>
|
|
<div style={{ color: 'var(--text-2)', direction: 'ltr', textAlign: 'right' }}>کد ملی: {lookup?.national_code}</div>
|
|
</div>
|
|
)}
|
|
|
|
{needsDetails && (
|
|
<>
|
|
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 10 }}>
|
|
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'}
|
|
</div>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<label style={labelSx}>نام و نام خانوادگی بیمار *</label>
|
|
<input
|
|
type="text"
|
|
value={patientName}
|
|
onChange={e => setPatientName(e.target.value)}
|
|
placeholder="مثال: علی محمدی"
|
|
style={inputSx}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label style={labelSx}>کد ملی بیمار *</label>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
lang="en"
|
|
maxLength={10}
|
|
value={nationalCode}
|
|
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
|
placeholder="کد ملی ۱۰ رقمی"
|
|
style={{ ...inputSx, direction: 'ltr' }}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label style={labelSx}>
|
|
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
|
|
</label>
|
|
<PriceInput
|
|
value={visitPriceToman}
|
|
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
|
style={{ ...inputSx, direction: 'ltr' }}
|
|
/>
|
|
{requireVisit && visitPriceToman <= 0 && (
|
|
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 6 }}>هزینه ویزیت الزامی است</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button className="btn sm" onClick={onClose}>انصراف</button>
|
|
<button
|
|
className="btn primary sm"
|
|
onClick={() => mutation.mutate()}
|
|
disabled={!isValid || mutation.isPending}
|
|
>
|
|
{mutation.isPending ? '...' : 'ثبت نوبت'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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);
|
|
const clinicUuid = useClinicContext();
|
|
const isAdmin = primaryRole === 'admin';
|
|
const isClinic = primaryRole === 'clinic';
|
|
const isDoctor = primaryRole === 'doctor';
|
|
const isRepresentation = primaryRole === 'representation';
|
|
|
|
const [params] = useSearchParams();
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
// پس از ویرایش/ثبت، صفحه با ?date=... باز میشود تا همان روز نمایش داده شود.
|
|
const [selectedDate, setSelectedDate] = useState(params.get('date') || today);
|
|
const [viewMode, setViewMode] = useState<TurnsViewMode>('timeline');
|
|
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
|
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];
|
|
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
|
|
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 }[] }>>({
|
|
queryKey: ['clinic-doctors', dbUuid],
|
|
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
|
enabled: isClinic && !!dbUuid,
|
|
});
|
|
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
|
|
|
const doctors = React.useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
clinicDoctorsList.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;
|
|
const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1;
|
|
const showDoctorCol = isAdmin && !selectedDoctorUuid;
|
|
|
|
// در کلینیک، اولین دکتر بهصورت پیشفرض انتخاب میشود تا زمانبندی مثل طرح پر باشد.
|
|
useEffect(() => {
|
|
if (isClinic && !selectedDoctorUuid && doctors.length > 0) {
|
|
setSelectedDoctorUuid(doctors[0].uuid);
|
|
}
|
|
}, [isClinic, selectedDoctorUuid, doctors]);
|
|
|
|
// ── Slots query (timeline)
|
|
// clinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده میشود.
|
|
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid];
|
|
const slotsQuery = useQuery<ApiResponse<any>>({
|
|
queryKey: slotsQueryKey,
|
|
queryFn: () => api.get(
|
|
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
|
|
(clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
|
|
),
|
|
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
|
|
});
|
|
|
|
// ── روش نوبتدهی پزشکِ انتخابشده (سرویسی/اسلاتی)
|
|
const { bookingMode, services } = useDoctorBookingServices(selectedDoctorUuid);
|
|
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) 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 }))}
|
|
/>
|
|
|
|
<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 && (
|
|
<button
|
|
className="btn primary sm"
|
|
onClick={() => {
|
|
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={setSelectedDoctorUuid} showAll={isAdmin} />
|
|
)}
|
|
<div style={{ padding: 16 }}>
|
|
{viewMode === 'table' ? (
|
|
<>
|
|
<TurnsTable
|
|
items={pagedAppointments}
|
|
loading={apptQuery.isLoading}
|
|
queryKey={apptQueryKey}
|
|
showDoctor={showDoctorCol}
|
|
/>
|
|
{filteredAppointments.length > TABLE_PAGE_SIZE && (
|
|
<div style={{ marginTop: 14 }}>
|
|
<Pagination page={tablePage} total={filteredAppointments.length} limit={TABLE_PAGE_SIZE} onPageChange={setTablePage} />
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{!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}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* مودال ثبت سریع نوبت */}
|
|
{bookingSlot && (
|
|
<NewAppointmentModal
|
|
slot={bookingSlot}
|
|
serviceMode={serviceMode}
|
|
services={services}
|
|
date={selectedDate}
|
|
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>
|
|
);
|
|
}
|