Files
clinicpro/assets/admin/components/appointments/ResourceBookingModal.tsx
T
hamedandClaude Opus 5 fd27ceef7d feat(appointments): book onto a resource from its tab, drop the read-only resource timeline
"Add appointment" while a resource tab is active now opens a booking modal for
that resource: its own services, then a time, then the responsible doctor.

It reuses the booking engine that already existed (appointment-availability →
appointment-hold → appointment-confirm) rather than adding a second path. That
engine answers service-first and returns a resource assignment per slot, so the
modal keeps only the slots where the engine actually offered this resource and
pins that role to it on hold. Showing the other slots would let an operator pick
a time that can only come back as a 409.

The responsible doctor is required because every appointment has a doctor and
confirm will not run without one; the resource records which device the work
happens on.

The read-only "منابع" timeline under the schedule is removed along with its
component and hook, which had no other consumers. GET /api/v1/resources/timeline
is untouched on the backend and now has no client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:43:33 +03:30

228 lines
9.3 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect';
import { api, type ApiResponse } from '../../lib/api';
import { formatNumber } from '../../lib/utils';
import {
REASON_LABELS,
useAvailabilitySearch,
useHold,
type AvailableSlot,
} from '../../hooks/useResourceBooking';
import type { ClinicResource, ResourceServiceOffering } from '../../types';
const DAY = 86400;
function hhmm(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** نقشی از تخصیص که این منبع در آن پیشنهاد شده — `null` یعنی این وقت مالِ منبع دیگری است. */
function roleHolding(slot: AvailableSlot, resourceUuid: string): string | null {
for (const [role, resources] of Object.entries(slot.assignment)) {
if (resources.some((r) => r.uuid === resourceUuid)) return role;
}
return null;
}
/**
* ثبت نوبت برای یک منبع مشخص — سرویس‌های خودِ منبع، بعد زمان، بعد پزشک مسئول.
*
* موتور همان `appointment-availability → hold → confirm` است که از قبل هست؛ تنها
* تفاوت این است که جستجو خدمت‌محور جواب می‌دهد و ما نتیجه را به وقت‌هایی تنگ می‌کنیم
* که موتور در آن‌ها **همین منبع** را پیشنهاد داده. وقتی که منبع در آن آزاد نبوده اصلاً
* نشان داده نمی‌شود، وگرنه اپراتور چیزی را انتخاب می‌کند که در `hold` خطای ۴۰۹ می‌گیرد.
*
* پزشک مسئول اجباری است: هر نوبت در این سیستم پزشک دارد و `confirm` بدونش کار نمی‌کند.
*/
export default function ResourceBookingModal({ resource, onClose, onBooked }: {
resource: ClinicResource;
onClose: () => void;
onBooked: () => void;
}) {
const { create, confirm } = useHold();
const [serviceUuid, setServiceUuid] = useState('');
const [days, setDays] = useState('7');
const [pickedSlot, setPickedSlot] = useState<AvailableSlot | null>(null);
const [doctorUuid, setDoctorUuid] = useState('');
const [submitting, setSubmitting] = useState(false);
// سرویس‌های همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمی‌دهد نباید
// در فهرست بیاید — سرور هم همان را با ۴۲۲ رد می‌کند.
const offeringsQuery = useQuery<ApiResponse<ResourceServiceOffering[]>>({
queryKey: ['resource-services', resource.uuid],
queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/services`),
});
const offerings = (offeringsQuery.data?.data ?? []).filter((o) => o.active);
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
queryKey: ['booking-doctors'],
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
staleTime: 60_000,
});
const doctors = doctorsQuery.data?.data?.data ?? [];
const range = useMemo(() => {
const from = Math.floor(Date.now() / 1000);
return { from, to: from + Number(days) * DAY };
}, [days]);
const { result, loading } = useAvailabilitySearch(
{ serviceUuid, branchUuid: resource.address_uuid, from: range.from, to: range.to },
!!serviceUuid,
);
const slots = useMemo(
() => (result?.slots ?? []).filter((s) => roleHolding(s, resource.uuid) !== null),
[result, resource.uuid],
);
const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null;
const canSubmit = !!serviceUuid && !!pickedSlot && !!doctorUuid && !submitting;
const submit = async () => {
if (!pickedSlot) return;
setSubmitting(true);
try {
// تخصیص همان چیزی است که موتور برای این وقت داده، با یک قید: نقشی که این منبع
// در آن آمده به خودش قفل می‌شود تا نوبت واقعاً روی همین دستگاه بنشیند.
const role = roleHolding(pickedSlot, resource.uuid);
const assignment = Object.fromEntries(
Object.entries(pickedSlot.assignment).map(([r, list]) => [
r,
r === role ? [resource.uuid] : list.map((x) => x.uuid),
]),
);
const held = await create.mutateAsync({
service_uuid: serviceUuid,
branch_uuid: resource.address_uuid,
start: pickedSlot.start,
assignment,
});
await confirm.mutateAsync({ hold_uuid: held.data.hold_uuid, doctor_uuid: doctorUuid });
onBooked();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal
open
title={`ثبت نوبت — ${resource.name}`}
size="sm"
onClose={onClose}
footer={
<>
<button type="button" className="btn ghost" onClick={onClose}>انصراف</button>
<button type="button" className="btn primary" disabled={!canSubmit} onClick={submit}>
{submitting ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
</>
}
>
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="rb-service">سرویس‌های این منبع <span className="req">*</span></label>
{offeringsQuery.isLoading ? (
<div className="skeleton" style={{ height: 40, borderRadius: 'var(--r-sm)' }} />
) : offerings.length === 0 ? (
<p className="field-err" style={{ marginTop: 0 }}>
برای این منبع سرویسی تعریف نشده است از تب «سرویس‌ها»ی همین منبع اضافه کنید.
</p>
) : (
<SearchableSelect
inputId="rb-service"
options={offerings.map((o) => ({
value: o.service_uuid,
label: o.effective_duration_minutes
? `${o.service_name} · ${formatNumber(o.effective_duration_minutes)} دقیقه`
: o.service_name,
}))}
value={serviceUuid || null}
onChange={(v) => { setServiceUuid(v ? String(v) : ''); setPickedSlot(null); }}
placeholder="سرویس را انتخاب کنید"
height={40}
/>
)}
</div>
{serviceUuid && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="rb-range">بازهٔ جستجو</label>
<SearchableSelect
inputId="rb-range"
options={[
{ value: '1', label: 'امروز' },
{ value: '7', label: 'یک هفتهٔ آینده' },
{ value: '30', label: 'یک ماه آینده' },
]}
value={days}
onChange={(v) => { setDays(v ? String(v) : '7'); setPickedSlot(null); }}
height={40}
/>
</div>
)}
{serviceUuid && (
<div style={{ marginBottom: 14 }}>
<span className="field-label">زمان‌های خالی این منبع</span>
{loading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>در حال محاسبه…</div>
) : slots.length === 0 ? (
<p className="field-err" style={{ marginTop: 0 }}>
{reasonText ?? 'برای این منبع در این بازه وقت آزادی نیست — بازه را بزرگ‌تر کنید.'}
</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{slots.slice(0, 40).map((s) => {
const active = pickedSlot?.start === s.start;
return (
<button
key={s.start}
type="button"
dir="ltr"
onClick={() => setPickedSlot(s)}
style={{
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', fontFamily: 'inherit',
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary)' : 'var(--surface)',
color: active ? 'var(--on-primary)' : 'var(--text)',
}}
>
{new Date(s.start * 1000).toLocaleDateString('fa-IR')} · {hhmm(s.start)}
</button>
);
})}
</div>
)}
</div>
)}
{pickedSlot && (
<div className="field-block">
<label htmlFor="rb-doctor">پزشک مسئول <span className="req">*</span></label>
<SearchableSelect
inputId="rb-doctor"
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
value={doctorUuid || null}
onChange={(v) => setDoctorUuid(v ? String(v) : '')}
placeholder="پزشک انجام‌دهنده"
height={40}
/>
<p className="field-hint">
هر نوبت پزشک مسئول دارد؛ منبع می‌گوید کار روی چه دستگاهی انجام می‌شود.
</p>
</div>
)}
</Modal>
);
}