Three related fixes, all rooted in the same flaw: authorization and scoping
decided by the caller's role instead of by the environment the data belongs to.
1. Single-appointment access (clinic operations were entirely broken)
AppointmentController::canView/canManage only knew the patient, the owning
doctor and admin -- appointment.clinic was never consulted. A clinic user could
create an appointment through /my/appointment but got 403 on detail, edit,
move, reserve transfer/replace and status change, so nearly every appointment
operation failed in clinic mode.
AppointmentAccessChecker now decides from appointment.clinic: clinic owner,
member doctor (via ClinicDoctorPermissionChecker) and assigned secretary (via
active context + DoctorSecretary) are recognised. Actions reuse the existing
permission vocabulary, so active=false remains the single source of truth for
"collaboration ended". Cancellation is gated separately and an inline status on
PATCH /appointment/{uuid} cannot bypass that gate. The patient is narrowed to
view + cancel.
Also fixed alongside: listByDoctor now serves a clinic manager but scoped to
that clinic; todayStats gained an admin branch and no longer passes an array of
doctor ids as the clinic parameter; PatientController::appointments filters on
appointment.clinic instead of current membership, so deactivating a doctor no
longer erases clinic appointment history from the case file.
The doctor-only active_slot_key was reviewed and deliberately left alone -- a
doctor is one physical person, so adding clinic to the key would permit
double-booking, not fix a bug. Reasoning recorded on the entity.
2. Appointment registration and confirmation
Panel-created appointments are born pending ("ثبت شده") instead of confirmed.
Confirming is now an explicit act: POST /appointment/{uuid}/confirm transitions
the status, files the case file for the appointment's environment (reusing an
existing record or creating one) and registers full or partial payments on the
resulting visit -- all in one transaction.
AppointmentExpiryService would have expired those pending appointments the
moment their slot time passed; findExpiredPending is now limited to online
gateway holds, which are the only pendings carrying a TTL. A pending
appointment still occupies its slot, so the time stays reserved.
The admin panel gets a "قطعی کردن نوبت" modal showing the visit fee, each
selected service, the total, and paid/remaining/status. It is wired inside
AppointmentStatusDropdown, so picking "confirmed" anywhere (timeline, detail,
reserve list, info modal) goes through it and confirmation can never silently
skip the case file and payment.
3. Clinic case-file access
PatientRecordScopeResolver replaces the single-destination role mapping: the
active context decides, so a doctor invited into a clinic finally sees their
patients' records there. A clinic record is per-patient and shared by design,
so "their own patients" is derived from appointments with that doctor in that
clinic rather than from a new column. Clinic secretaries are limited to their
assigned doctors. Read and write share one rule, and out-of-scope records
report 404 so other environments are never disclosed.
Tests: 29 new cases across the three areas (clinic appointment access, confirm
flow, clinic record access). Full suite 466 tests, 2 pre-existing failures
unchanged. API docs updated for all three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
237 lines
12 KiB
TypeScript
237 lines
12 KiB
TypeScript
import { useEffect, useRef, useState } 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 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);
|
|
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 }}>
|
|
{a.status === 'pending' && (
|
|
<>
|
|
<button type="button" className="btn primary sm" onClick={() => setConfirmOpen(true)}>
|
|
قطعی کردن نوبت
|
|
</button>
|
|
<ConfirmAppointmentModal
|
|
open={confirmOpen}
|
|
appointmentUuid={a.uuid}
|
|
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>
|
|
);
|
|
}
|