feat(treatment): bind an appointment to a chosen session, and free it on cancel

Two holes in how a course's later appointments were made.

The link from the unbooked queue carried nothing — `/admin/appointments/new`
with no parameters — so the secretary retyped the patient and the service, and
which case the appointment joined was inferred from the service they happened to
pick. A patient with two open courses had no way to say which one they meant,
and picking the wrong service silently opened a third case. (The suggestion link
did pass slot_start and resource_uuid, but the create page never read either.)

POST /api/v1/my/appointment now takes an optional treatment_session_uuid.
SessionBookingLink validates it — same tenant, still unbooked, case open, same
patient — and reserves that session. Confirm-time attachment steps aside when
the appointment already holds a session. The booking form states in words which
session, which course and which patient it is about to book, read from a new
GET /api/v1/treatment-session/{uuid}.

Nothing ever detached a session from its appointment, so a cancelled booking
left the session `booked` forever, and since findNextUnbooked requires
"has no appointment", it could never return to the queue. Cancellation and
no-show now release it back to `planned`. A finished session is history and is
left alone.

The system still never books the next appointment by itself — it only suggests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 15:44:22 +03:30
co-authored by Claude Opus 5
parent d857242145
commit c7a3b88b32
9 changed files with 308 additions and 4 deletions
+54 -1
View File
@@ -19,7 +19,7 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import BackButton from '../components/ui/BackButton';
import { digitsOnly, todayIso } from '../lib/utils';
import { digitsOnly, todayIso, formatNumber } from '../lib/utils';
import Switch from '../components/ui/Switch';
/**
@@ -104,6 +104,26 @@ export default function AppointmentCreatePage() {
// ورود از تب «نوبت‌ها»ی پروندهٔ بیمار: مراجعه‌کننده از قبل معلوم است، پس به‌جای
// جستجو، خودِ پرونده خوانده و قفل می‌شود.
const fromRecordUuid = params.get('record') ?? '';
/**
* «ثبت نوبت این جلسه» — بیمار می‌تواند چند دورهٔ باز داشته باشد، پس فرم باید بگوید
* این نوبت به کدام جلسه می‌چسبد و همان را به سرور بفرستد. بدونش اتصال به حدسِ
* سرویس سپرده می‌شود و سرویسِ اشتباه یک پروندهٔ موازی می‌سازد.
*/
const targetSessionUuid = params.get('session') ?? '';
const targetSessionQ = useQuery<ApiResponse<{
uuid: string;
session_number: number;
total_sessions: number;
case_uuid: string;
service: { uuid: string; name: string };
patient: { record_uuid: string; name: string | null };
}>>({
queryKey: ['create-appt-session', targetSessionUuid],
queryFn: () => api.get(`/api/v1/treatment-session/${targetSessionUuid}`),
enabled: !!targetSessionUuid,
});
const targetSession = targetSessionQ.data?.data;
const recordQ = useQuery<ApiResponse<PatientRow & { user_name?: string }>>({
queryKey: ['create-appt-record', fromRecordUuid],
queryFn: () => api.get(`/api/v1/patient/${fromRecordUuid}`),
@@ -208,6 +228,7 @@ export default function AppointmentCreatePage() {
// و در حالت منبع، منبعِ کلینیک اصلاً «متعلق به این محیط» شناخته نمی‌شود (۴۲۲).
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(resourceMode ? { resource_uuid: activeResource!.uuid } : {}),
...(targetSessionUuid ? { treatment_session_uuid: targetSessionUuid } : {}),
...(serviceMode
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true, service_durations: servicePick.durations }
: {
@@ -255,6 +276,38 @@ export default function AppointmentCreatePage() {
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
</div>
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددوره‌ای بدون این،
اتصال را به حدسِ سرویس می‌سپارد. */}
{targetSessionUuid !== '' && (
<div style={{
marginBottom: 16, padding: '12px 16px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-soft)', display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13,
}}>
{targetSessionQ.isLoading ? (
<span style={{ color: 'var(--text-3)' }}>در حال خواندن جلسهٔ درمان</span>
) : targetSession ? (
<>
<span>
<span style={{ color: 'var(--text-3)' }}>این نوبت برای: </span>
<b>جلسهٔ {formatNumber(targetSession.session_number)} از {formatNumber(targetSession.total_sessions)}</b>
</span>
<span>
<span style={{ color: 'var(--text-3)' }}>دوره: </span>
<b>{targetSession.service.name}</b>
</span>
<span>
<span style={{ color: 'var(--text-3)' }}>بیمار: </span>
<b>{targetSession.patient.name || 'بدون نام'}</b>
</span>
</>
) : (
<span style={{ color: 'var(--danger)' }}>
جلسهٔ درمان یافت نشد نوبت بدون اتصال به دوره ثبت میشود.
</span>
)}
</div>
)}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 28 }}>
{/* پزشک — فقط برای admin/clinic */}
{!isDoctor && (
+6 -2
View File
@@ -452,7 +452,11 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
<button type="button" className="btn secondary sm" onClick={() => setOpen(true)}>
پیشنهاد وقت
</button>
<Link to="/admin/appointments/new" className="btn primary sm">ثبت نوبت این جلسه</Link>
{/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال
به حدسِ سرویس سپرده می‌شود. */}
<Link to={`/admin/appointments/new?session=${sessionUuid}`} className="btn primary sm">
ثبت نوبت این جلسه
</Link>
</div>
);
}
@@ -483,7 +487,7 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
{day.slots.slice(0, 6).map((slot) => (
<Link
key={slot.start}
to={`/admin/appointments/new?slot_start=${slot.start}&resource_uuid=${data?.data?.resource_uuid ?? ''}`}
to={`/admin/appointments/new?session=${sessionUuid}&date=${day.date}`}
className="btn secondary sm"
title={formatDateTime(slot.start)}
>