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>
This commit is contained in:
hamed
2026-08-03 11:43:33 +03:30
co-authored by Claude Opus 5
parent 993478fcc0
commit fd27ceef7d
6 changed files with 353 additions and 456 deletions
+15 -23
View File
@@ -8,7 +8,7 @@ import {
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { Appointment } from '../types';
import type { Appointment, ClinicResource } from '../types';
import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
import Modal from '../components/ui/Modal';
@@ -23,12 +23,11 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
import ResourceTimeline from '../components/appointments/ResourceTimeline';
import { useResourceTimeline } from '../hooks/useResourceTimeline';
import DoctorTabs from '../components/appointments/DoctorTabs';
/** هیچ تبی فعال نیست — وقتی تب منبع انتخاب شده، نوار پزشکان نباید هایلایت داشته باشد. */
const NO_ACTIVE_TAB = '\u0000';
import { useResources } from '../hooks/useResources';
import ResourceBookingModal from '../components/appointments/ResourceBookingModal';
import { useUrlState } from '../hooks/useUrlState';
import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable';
@@ -528,6 +527,9 @@ export default function AppointmentsPage() {
const selectedResourceUuid = urlState.resource;
const { resources: bookableResources } = useResources({ active: '1' });
const [bookingResource, setBookingResource] = useState<ClinicResource | null>(null);
const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null;
const selectResource = (uuid: string) => {
setUrlState({ resource: uuid });
if (uuid) setSelectedDoctorUuid('');
@@ -643,14 +645,6 @@ export default function AppointmentsPage() {
const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null;
const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid;
// ردیف منابع بخشی از همان نمای زمانبندی است: در مدل منبع‌محور، پرشدن یک ساعت را
// دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک. در نمای جدولی کوئری نمی‌رود.
const {
day: resourceDay,
loading: resourceTimelineLoading,
error: resourceTimelineError,
} = useResourceTimeline(viewMode === 'timeline' ? selectedDate : '', { onlyBookable: true });
// ── Slots query (timeline)
// effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود.
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid];
@@ -846,6 +840,8 @@ export default function AppointmentsPage() {
<button
className="btn primary sm"
onClick={() => {
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویس‌های خودش.
if (activeResource) { setBookingResource(activeResource); return; }
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
navigate(`/admin/appointments/new${q}`);
}}
@@ -932,24 +928,20 @@ export default function AppointmentsPage() {
/>
</>
)}
{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت می‌تواند هم‌زمان اتاق و
دستگاه را بگیرد و آن است که ظرفیت را تمام می‌کند. */}
<div style={{ marginTop: 'var(--gap)', paddingTop: 'var(--gap)', borderTop: '1px solid var(--border)' }}>
<h2 className="section-title" style={{ margin: '0 0 12px', fontSize: 15 }}>منابع</h2>
<ResourceTimeline
lanes={resourceDay?.resources ?? []}
dayStart={resourceDay?.date ?? 0}
loading={resourceTimelineLoading}
error={resourceTimelineError}
/>
</div>
</>
)}
</div>
</div>
</div>
{bookingResource && (
<ResourceBookingModal
resource={bookingResource}
onClose={() => setBookingResource(null)}
onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })}
/>
)}
{/* مودال ثبت سریع نوبت */}
{bookingSlot && (
<NewAppointmentModal