- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
761 lines
39 KiB
TypeScript
761 lines
39 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Link, useNavigate, useSearchParams } from 'react-router';
|
|
import {
|
|
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
|
|
AdjustmentsHorizontalIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { api } from '../lib/api';
|
|
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
|
import type { Appointment, ClinicResource } from '../types';
|
|
import { formatDate, toGregorianDate, todayIso, formatTime } from '../lib/utils';
|
|
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 ResourceDayPanel from '../components/appointments/ResourceDayPanel';
|
|
import { useResourceBookingServices } from '../hooks/useResourceBookingServices';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
|
import TurnsTable from '../components/appointments/TurnsTable';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
|
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
|
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
|
import { buildServiceTimeline } from '../components/appointments/serviceTimeline';
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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 { services: resourceServices } = useResourceBookingServices(bookingResource?.uuid);
|
|
|
|
/**
|
|
* منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک
|
|
* تنگ میکند. پاک کردن پزشک، نوبتدهی خودِ او را از دسترس خارج میکرد.
|
|
*/
|
|
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, isDoctor],
|
|
// منشی، کلینیک و خودِ پزشک از اندپوینتِ احرازشده میگیرند: هم فقط پزشکانِ مجاز را
|
|
// میدهد و هم `has_schedule`ِ همین محیط را. پزشک هم لازمش دارد — بدون آن،
|
|
// پروفایل خودش نمیداند که «ساعت کاری تنظیم نشده» و فقط تایملاین خالی میبیند.
|
|
// ادمین از لیستِ کلینیکِ انتخابشده میخواند (آن اندپوینت نقش ادمین را پوشش
|
|
// نمیدهد) و آنجا فلگ نمیآید.
|
|
queryFn: () => api.get(
|
|
isSecretary || isClinic || isDoctor
|
|
? '/api/v1/my/clinic-doctors'
|
|
: `/api/v1/clinic/doctor-list/${dbUuid}`,
|
|
),
|
|
enabled: isSecretary || isDoctor || (isClinic && !!dbUuid),
|
|
});
|
|
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
|
|
|
const doctors = React.useMemo(() => {
|
|
const map = new Map<string, { name: string; hasSchedule: boolean }>();
|
|
// پزشکِ بدون برنامهٔ کاری هم تب میگیرد: منابعِ تحت نظرش تقویم مستقلِ خودشان را
|
|
// دارند و قابل نوبتدهیاند، پس پنهانکردنِ پزشک آن منابع را هم از دسترس خارج
|
|
// میکرد. بهجای حذف، تبش برچسب «بدون ساعت کاری» میگیرد.
|
|
// نبودِ فیلد (مسیر ادمین) یعنی «نمیدانیم»، پس برچسب نمیخورد.
|
|
clinicDoctorsList.forEach(d => map.set(d.uuid, { name: d.name, hasSchedule: d.has_schedule !== false }));
|
|
appointments.forEach(a => {
|
|
if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) {
|
|
map.set(a.doctor_uuid, { name: a.doctor_name, hasSchedule: true });
|
|
}
|
|
});
|
|
return Array.from(map.entries()).map(([uuid, v]) => ({ uuid, name: v.name, hasSchedule: v.hasSchedule }));
|
|
}, [appointments, clinicDoctorsList]);
|
|
|
|
const selectedDoctor = doctors.find(d => d.uuid === selectedDoctorUuid) ?? null;
|
|
/** پزشکِ انتخابشده برنامهٔ هفتگی ندارد ⇒ تایملاین جای خود را به راهنما میدهد. */
|
|
const doctorHasNoSchedule = selectedDoctor?.hasSchedule === false;
|
|
|
|
/**
|
|
* صفحهٔ تنظیماتِ نوبتدهی که همین کاربر واقعاً میتواند بازش کند.
|
|
*
|
|
* مالک کلینیک و منشیِ محیط کلینیک از تنظیمات کلینیک وارد میشوند (با تبِ همان
|
|
* پزشک)؛ پزشک و منشیِ مطب شخصی از صفحهٔ خودِ پزشک که انتخابگر محیط دارد. بدون
|
|
* مجوز `appointment_settings.view` لینک اصلاً نمیآید — همان گیتی که `RoleRoute`
|
|
* روی هر دو مسیر میگذارد، وگرنه کلیک به داشبورد پرت میشد.
|
|
*/
|
|
/**
|
|
* محیطهای کلینیکیِ دیگرِ همین کاربر، وقتی خودش در مطب شخصی ایستاده.
|
|
*
|
|
* پزشکِ مهمانِ یک کلینیک، بهطور پیشفرض در محیط شخصی وارد پنل میشود و آنجا نه
|
|
* برنامهٔ کلینیک را میبیند نه منابعش (هر دو tenant-scopedاند) — بدون این راهنما،
|
|
* «چیزی نیست» با «جای دیگری است» اشتباه گرفته میشد.
|
|
*/
|
|
const availableContexts = useAuthStore(s => s.availableContexts);
|
|
const otherClinicNames = clinicUuid === null
|
|
? availableContexts.filter(c => c.type === 'clinic').map(c => c.name)
|
|
: [];
|
|
|
|
const apptSettingsHref = !can('appointment_settings', 'view')
|
|
? null
|
|
: isClinic || isClinicScopedSecretary
|
|
? `/admin/settings/appointment-settings?scope=doctors&doctor=${selectedDoctorUuid}`
|
|
: isDoctor || isSecretary
|
|
? '/admin/appointment-settings'
|
|
: null;
|
|
|
|
// سرویسهای موجود در نوبتهای امروز (برای فیلتر «سرویس مورد نظر...»).
|
|
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 && !doctorHasNoSchedule,
|
|
});
|
|
|
|
// ── روش نوبتدهی پزشکِ انتخابشده (سرویسی/اسلاتی)
|
|
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 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 windows = rawSessions.flatMap((session: any) => {
|
|
const slots = (session.slots as any[]) ?? [];
|
|
if (!slots.length) return [];
|
|
const dayStart = Number(slots[0].start) - parseHM(session.start_time);
|
|
return [{ start: dayStart + parseHM(session.start_time), end: dayStart + parseHM(session.end_time) }];
|
|
});
|
|
|
|
return buildServiceTimeline(windows, appointments);
|
|
}
|
|
|
|
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.map(d => ({
|
|
uuid: d.uuid,
|
|
name: d.name,
|
|
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
|
|
}))}
|
|
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}
|
|
date={selectedDate}
|
|
appointments={filteredAppointments}
|
|
loading={apptQuery.isLoading}
|
|
canCreate={!isRepresentation && canCreateAppt}
|
|
queryKey={apptQueryKey}
|
|
onBook={() => setBookingResource(activeResource)}
|
|
onView={openDetail}
|
|
/>
|
|
) : (
|
|
<>
|
|
{!selectedDoctorUuid ? (
|
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
|
|
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
|
|
</div>
|
|
) : doctorHasNoSchedule ? (
|
|
/* بدون برنامهٔ هفتگی، خودِ پزشک اسلاتی ندارد؛ اما منابعِ تحت نظرش
|
|
تقویم مستقل دارند و همچنان قابل نوبتدهیاند. */
|
|
<NoScheduleNotice
|
|
settingsHref={apptSettingsHref}
|
|
otherClinicNames={otherClinicNames}
|
|
resources={supervisedResources}
|
|
canCreate={!isRepresentation && canCreateAppt}
|
|
onBookResource={setBookingResource}
|
|
onOpenResource={selectResource}
|
|
/>
|
|
) : (
|
|
<>
|
|
{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 && (
|
|
<NewAppointmentModal
|
|
slot={{
|
|
start: 0, end: 0, start_time: '', end_time: '',
|
|
// پزشکِ نوبت همان ناظرِ منبع است؛ اینجا فقط برای نمایش و تعرفهٔ ویزیت
|
|
// میآید و سرور خودش هم از منبع استنتاجش میکند.
|
|
doctor_uuid: bookingResource.supervisor?.uuid ?? '',
|
|
doctor_name: bookingResource.supervisor?.name ?? '',
|
|
}}
|
|
resource={{ uuid: bookingResource.uuid, name: bookingResource.name }}
|
|
services={resourceServices}
|
|
date={selectedDate}
|
|
clinicUuid={effectiveClinicUuid}
|
|
onClose={() => setBookingResource(null)}
|
|
onSuccess={() => {
|
|
qc.invalidateQueries({ queryKey: apptQueryKey });
|
|
qc.invalidateQueries({ queryKey: ['resource-day-slots', bookingResource.uuid, selectedDate] });
|
|
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* مودال ثبت سریع نوبت */}
|
|
{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 NoScheduleNotice({ settingsHref, otherClinicNames, resources, canCreate, onBookResource, onOpenResource }: {
|
|
settingsHref: string | null;
|
|
/** کلینیکهایی که کاربر عضوشان است ولی الان در محیطشان نیست. */
|
|
otherClinicNames: string[];
|
|
resources: ClinicResource[];
|
|
canCreate: boolean;
|
|
onBookResource: (r: ClinicResource) => void;
|
|
onOpenResource: (uuid: string) => void;
|
|
}) {
|
|
return (
|
|
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
|
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>ساعت کاری تنظیم نشده است</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
|
برای این پزشک برنامهٔ هفتگی تعریف نشده، پس تایملاین نوبتهایش خالی است.
|
|
</div>
|
|
{settingsHref && (
|
|
<Link to={settingsHref} className="btn primary sm" style={{ display: 'inline-flex', marginTop: 14 }}>
|
|
تنظیم ساعت کاری
|
|
</Link>
|
|
)}
|
|
|
|
{resources.length === 0 && otherClinicNames.length > 0 && (
|
|
<div style={{
|
|
marginTop: 18, paddingTop: 16, borderTop: '1px solid var(--border)',
|
|
fontSize: 12.5, color: 'var(--text-2)', lineHeight: 2,
|
|
}}>
|
|
شما الان در محیط «مطب شخصی» هستید. برنامهٔ کاری و منابعِ
|
|
{' '}{otherClinicNames.map(n => `«${n}»`).join('، ')}{' '}
|
|
در محیط همان کلینیک تعریف میشوند.
|
|
<br />
|
|
<Link to="/admin/select-context" className="btn secondary sm" style={{ display: 'inline-flex', marginTop: 8 }}>
|
|
تغییر محیط کاری
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{resources.length > 0 && (
|
|
<div style={{
|
|
marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border)',
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
|
|
}}>
|
|
<div style={{ fontSize: 12.5, color: 'var(--text-2)' }}>
|
|
منابع تحت نظر این پزشک تقویم مستقل دارند و همچنان قابل نوبتدهی هستند:
|
|
</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
|
{resources.map((r) => (
|
|
<div key={r.uuid} style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
|
|
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
|
}}>
|
|
<button
|
|
type="button"
|
|
className="btn sm"
|
|
onClick={() => onOpenResource(r.uuid)}
|
|
style={{ background: 'none', border: 'none', color: 'var(--text)', fontSize: 13, padding: 0 }}
|
|
>
|
|
{r.name}
|
|
</button>
|
|
{canCreate && (
|
|
<button type="button" className="btn primary sm" onClick={() => onBookResource(r)}>
|
|
ثبت نوبت
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|