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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user