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>
210 lines
11 KiB
TypeScript
210 lines
11 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon } from '@heroicons/react/24/outline';
|
|
import type { Appointment } from '../../types';
|
|
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
|
|
import AppointmentActionsMenu from '../AppointmentActions';
|
|
import { turnStatusConfig, EMPTY_SLOT_CONFIG } from './turnStatus';
|
|
import type { TimelineSlot } from './types';
|
|
|
|
/**
|
|
* نمای زمانبندی (زمانبندی) — بازسازیِ `Timeline.jsx` طرح tauri:
|
|
* هر ردیف: «ریل مارکر» بیرونی (نقطهٔ رنگی + ساعت شروع + خطچین) سمت راست + «کارت
|
|
* نوبت» چپِ آن. داخل کارت: مارکر زمانِ داخلی (نقطهٔ حلقهای شروع/پایان)، اطلاعات
|
|
* بیمار (نام/تلفن/سرویس) و در سمت چپ وضعیت + عملیات. اسلات خالی → «افزودن نوبت».
|
|
* ردیفها با max-width وسطچین میشوند. رنگها عیناً از طرح مبدأ.
|
|
*/
|
|
|
|
const ROW_MAX = 760;
|
|
|
|
// نقطهٔ حلقهای (حلقهٔ بیرونی + نقطهٔ داخلی) — مثل TimelineElement طرح tauri.
|
|
function RingDot({ color }: { color: string }) {
|
|
return (
|
|
<span style={{ position: 'relative', width: 17, height: 17, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<span style={{ position: 'absolute', width: 16, height: 16, borderRadius: '50%', border: `2px solid ${color}` }} />
|
|
<span style={{ width: 7, height: 7, borderRadius: '50%', background: color }} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// مارکر زمانِ داخلِ کارت (ساعت شروع بالا، ساعت پایان پایین + خطچین بین آنها).
|
|
function InnerTimes({ start, end, color }: { start: string; end: string; color: string }) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 4, minWidth: 62, alignSelf: 'stretch' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<RingDot color={color} />
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 38, textAlign: 'center' }}>{start}</span>
|
|
</div>
|
|
<div style={{ width: 2, height: 14, marginInlineEnd: 6, backgroundImage: `repeating-linear-gradient(to bottom, ${color} 0, ${color} 3px, transparent 3px, transparent 6px)` }} />
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<RingDot color={color} />
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 38, textAlign: 'center' }}>{end}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ریل مارکر بیرونی (سمت راستِ ردیف): نقطهٔ رنگی + ساعت + خطچین عمودی.
|
|
function OuterMarker({ color, time, showLine }: { color: string; time: string; showLine: boolean }) {
|
|
return (
|
|
<div style={{ width: 55, position: 'relative', display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-start' }}>
|
|
{showLine && (
|
|
<div style={{
|
|
position: 'absolute', right: 5, top: 18, height: 'calc(100% + 16px)', width: 2,
|
|
backgroundImage: 'repeating-linear-gradient(to bottom, var(--border-2) 0, var(--border-2) 4px, transparent 4px, transparent 8px)',
|
|
zIndex: 0,
|
|
}} />
|
|
)}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, position: 'relative', zIndex: 1 }}>
|
|
<span style={{ width: 12, height: 12, borderRadius: '50%', background: color, border: '2px solid var(--surface)', boxShadow: '0 0 0 1px var(--border)', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 42 }}>{time}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyCard({ slot, onBook }: { slot: TimelineSlot; onBook: (s: TimelineSlot) => void }) {
|
|
const cfg = EMPTY_SLOT_CONFIG;
|
|
const isPast = slot.start < Math.floor(Date.now() / 1000);
|
|
return (
|
|
<div
|
|
onClick={() => !isPast && onBook(slot)}
|
|
style={{
|
|
background: isPast ? 'var(--surface-2)' : cfg.bgColor,
|
|
border: `1px dashed ${isPast ? 'var(--border-2)' : cfg.borderColor}`,
|
|
borderRadius: 8, padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 14,
|
|
cursor: isPast ? 'not-allowed' : 'pointer', opacity: isPast ? 0.7 : 1, minHeight: 76,
|
|
}}
|
|
>
|
|
<InnerTimes start={slot.start_time} end={slot.end_time} color={isPast ? '#9E9E9E' : cfg.dotColor} />
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
|
<span style={{ fontSize: 13.5, color: isPast ? 'var(--text-3)' : cfg.textColor, fontWeight: 500 }}>
|
|
{isPast ? 'گذشته' : 'افزودن نوبت سریع'}
|
|
</span>
|
|
{!isPast && <PlusIcon style={{ width: 18, height: 18, color: cfg.textColor }} />}
|
|
</div>
|
|
{/* برچسب «نوبت جدید» سمت چپ (مطابق طرح) */}
|
|
{!isPast && (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px', borderRadius: 99,
|
|
fontSize: 12, fontWeight: 700, color: cfg.textColor, background: `${cfg.dotColor}15`,
|
|
border: `1.5px solid ${cfg.dotColor}30`, whiteSpace: 'nowrap', alignSelf: 'flex-start',
|
|
}}>
|
|
<span style={{ width: 7, height: 7, borderRadius: '50%', background: cfg.dotColor }} />
|
|
نوبت جدید
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OccupiedCard({
|
|
appointment: a, queryKey, onView,
|
|
}: {
|
|
appointment: Appointment; queryKey: unknown[]; onView: (a: Appointment) => void;
|
|
}) {
|
|
const cfg = turnStatusConfig(a.status);
|
|
return (
|
|
<div
|
|
onClick={() => onView(a)}
|
|
style={{
|
|
background: cfg.bgColor, border: `1px solid ${cfg.borderColor}`, borderRadius: 8,
|
|
padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 14, cursor: 'pointer', minHeight: 88,
|
|
}}
|
|
>
|
|
<InnerTimes start={a.appointment_time} end={a.end_time} color={cfg.dotColor} />
|
|
|
|
{/* اطلاعات بیمار */}
|
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
<UserIcon style={{ width: 16, height: 16, color: 'var(--text-3)', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{a.patient_name || '—'}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
<PhoneIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-2)', direction: 'ltr' }}>{a.patient_mobile}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
<DocumentTextIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 11.5, color: 'var(--text-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
سرویس: {a.service_item?.name || '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
|
|
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start', flexShrink: 0 }}>
|
|
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
|
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* چرا این روز اسلاتی ندارد — از `empty_reason` پاسخ appointment-slots.
|
|
* خالیبودن لزوماً تعطیلی نیست.
|
|
*/
|
|
const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
|
|
no_schedule: { title: 'برنامهٔ نوبتدهی ثبت نشده', hint: 'برای این محل هنوز برنامهٔ کاری تعریف نشده است' },
|
|
holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' },
|
|
day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' },
|
|
outside_window: { title: 'خارج از بازهٔ نوبتدهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبتدهی آنلاین خاموش است' },
|
|
};
|
|
|
|
export default function TurnsTimeline({
|
|
slots, loading, queryKey, onView, onBook, emptyReason,
|
|
}: {
|
|
slots: TimelineSlot[];
|
|
emptyReason?: string | null;
|
|
loading: boolean;
|
|
queryKey: unknown[];
|
|
onView: (a: Appointment) => void;
|
|
onBook: (s: TimelineSlot) => void;
|
|
}) {
|
|
const activeRef = useRef<HTMLDivElement | null>(null);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const activeIndex = slots.findIndex(s => s.appointment && s.end >= now);
|
|
|
|
useEffect(() => {
|
|
if (!activeRef.current) return;
|
|
const el = activeRef.current;
|
|
const top = el.getBoundingClientRect().top + window.scrollY - window.innerHeight * 0.4;
|
|
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
|
}, [activeIndex]);
|
|
|
|
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
|
if (!slots.length) {
|
|
const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off;
|
|
return (
|
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>{reason.title}</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>{reason.hint}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', padding: '4px 0' }}>
|
|
{slots.map((slot, i) => {
|
|
const color = slot.appointment ? turnStatusConfig(slot.appointment.status).dotColor : EMPTY_SLOT_CONFIG.dotColor;
|
|
return (
|
|
<div
|
|
key={`${slot.start}-${i}`}
|
|
ref={i === activeIndex ? activeRef : null}
|
|
style={{ display: 'flex', flexDirection: 'row-reverse', gap: 16, position: 'relative', width: '100%', maxWidth: ROW_MAX }}
|
|
>
|
|
<OuterMarker color={color} time={slot.start_time} showLine={i < slots.length - 1} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{slot.appointment
|
|
? <OccupiedCard appointment={slot.appointment} queryKey={queryKey} onView={onView} />
|
|
: <EmptyCard slot={slot} onBook={onBook} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|