660 lines
30 KiB
TypeScript
660 lines
30 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 } from '../lib/utils';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
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';
|
||
// اجزای طرح نوبتهای 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';
|
||
|
||
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 isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid;
|
||
|
||
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 } : {}),
|
||
}),
|
||
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) {
|
||
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
||
const normalized = v
|
||
.replace(/[۰-۹]/g, d => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
||
.replace(/[٠-٩]/g, d => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
|
||
.replace(/\D/g, '')
|
||
.slice(0, 11);
|
||
setMobile(normalized);
|
||
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"
|
||
maxLength={10}
|
||
value={nationalCode}
|
||
onChange={e => setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||
placeholder="کد ملی ۱۰ رقمی"
|
||
style={{ ...inputSx, direction: 'ltr' }}
|
||
/>
|
||
</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 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)
|
||
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
|
||
const slotsQuery = useQuery<ApiResponse<any>>({
|
||
queryKey: slotsQueryKey,
|
||
queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`),
|
||
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}
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</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 (
|
||
<select aria-label="سرویس" value={value} onChange={e => onChange(e.target.value)}
|
||
style={{ height: 44, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 12px', minWidth: 280 }}>
|
||
<option value="">سرویس مورد نظر را انتخاب کنید...</option>
|
||
{options.map(s => <option key={s.uuid} value={s.uuid}>{s.name}</option>)}
|
||
</select>
|
||
);
|
||
}
|