Files
clinicpro/assets/admin/pages/AppointmentsPage.tsx
T
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:38:56 +03:30

693 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 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 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 (
<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>
);
}