A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.
Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.
Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
14 KiB
TypeScript
264 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon, ShieldCheckIcon } from '@heroicons/react/24/outline';
|
|
import type { Appointment } from '../../types';
|
|
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
|
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
|
|
import AppointmentActionsMenu from '../AppointmentActions';
|
|
import ConfirmAppointmentModal from './ConfirmAppointmentModal';
|
|
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 ? 'var(--text-3)' : 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: `color-mix(in srgb, ${cfg.dotColor} 8%, transparent)`,
|
|
border: `1.5px solid color-mix(in srgb, ${cfg.dotColor} 19%, transparent)`, 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);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
// نام بیمه فقط با نگاشت از قراردادهای کششده به دست میآید؛ payload نوبت نامی ندارد
|
|
// تا لیستهای نوبت به N+1 نیفتند.
|
|
const insurance = useAppointmentInsurance(!!a.insurance_base_id, a.doctor_uuid ?? null);
|
|
const insuranceChip = [
|
|
a.insurance_service_category_label,
|
|
insurance.insuranceNameOf(a.insurance_base_id),
|
|
].filter(Boolean).join(' · ');
|
|
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>
|
|
{insuranceChip !== '' && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
<ShieldCheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />
|
|
<span style={{
|
|
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 'var(--r-pill)',
|
|
background: 'var(--primary-soft)', color: 'var(--primary)',
|
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
}}>
|
|
{insuranceChip}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
|
|
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start', flexShrink: 0 }}>
|
|
{a.status === 'pending' && (
|
|
<>
|
|
<button type="button" className="btn primary sm" onClick={() => setConfirmOpen(true)}>
|
|
قطعی کردن نوبت
|
|
</button>
|
|
<ConfirmAppointmentModal
|
|
open={confirmOpen}
|
|
appointmentUuid={a.uuid}
|
|
appointment={a}
|
|
onClose={() => setConfirmOpen(false)}
|
|
queryKey={queryKey}
|
|
/>
|
|
</>
|
|
)}
|
|
<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: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبتدهی آنلاین خاموش است' },
|
|
// دلایلِ تقویمِ منبع — `resource/{uuid}/day-slots`.
|
|
no_shift: { title: 'این روز شیفت کاری ندارد', hint: 'در تقویم این منبع، برای این روز شیفتی تعریف نشده است' },
|
|
national_holiday: { title: 'تعطیل رسمی', hint: 'این روز در تقویم رسمی تعطیل است' },
|
|
tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' },
|
|
exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' },
|
|
resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبتدهی، منبع را از صفحهٔ «منابع» فعال کنید' },
|
|
};
|
|
|
|
export default function TurnsTimeline({
|
|
slots, loading, queryKey, onView, onBook, emptyReason, errorMessage,
|
|
}: {
|
|
slots: TimelineSlot[];
|
|
emptyReason?: string | null;
|
|
errorMessage?: 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 (errorMessage) {
|
|
return (
|
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--danger)' }}>خطا در دریافت برنامهٔ این روز</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>{errorMessage}</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (!slots.length) {
|
|
// «شیفت کاری ندارد» فقط وقتی که backend صریحاً day_off گفته باشد؛
|
|
// دلیل ناشناخته/غایب نباید به تعطیلی تفسیر شود.
|
|
const reason = EMPTY_REASON_TEXT[emptyReason ?? '']
|
|
?? { title: 'برنامهٔ این روز در دسترس نیست', hint: 'اطلاعات برنامهٔ کاری برای این روز دریافت نشد' };
|
|
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>
|
|
);
|
|
}
|