Files
clinicpro/assets/admin/components/appointments/TurnsTimeline.tsx
T
hamedandClaude Opus 5 1f58b1b9b3 feat(insurance): bill an appointment with a chosen service kind and insurance
An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:50:14 +03:30

258 lines
13 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 ? '#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);
const [confirmOpen, setConfirmOpen] = useState(false);
// نام بیمه فقط با نگاشت از قراردادهای کش‌شده به دست می‌آید؛ payload نوبت نامی ندارد
// تا لیست‌های نوبت به N+1 نیفتند.
const insurance = useAppointmentInsurance(!!a.insurance_base_id);
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: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبت‌دهی آنلاین خاموش است' },
};
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>
);
}