"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>
228 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|