From 732fbd462f94073ab739d7b0066c57092b7fe13a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 12:58:13 +0330 Subject: [PATCH] fix(appointments): a resource is a view inside its doctor, and never slot-based MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reported bugs, one root cause: resource tabs were built as a rival selection to the doctor rather than a narrower view within them. Selecting a resource cleared the doctor. The auto-select effect then quietly put the *first* doctor back, so anyone working under the second doctor was thrown to the first and lost that doctor's own booking. Selecting a resource now leaves the doctor alone; only picking a doctor clears the resource. A resource tab also still rendered the doctor's slot timeline, just with no data. Resources have no slotted weekly schedule — their calendar comes from service duration and real occupancy — so showing a slot grid promises times the booking engine does not recognise. The resource tab now renders its own panel: that day's appointments on the resource plus a service booking entry point. Both regressions are pinned by tests, and both were checked by reverting each fix in turn. The first attempt at the doctor-retention test passed even with the bug restored, because the auto-select effect masked it; it was rewritten to use the second doctor, where the bounce is observable. Co-Authored-By: Claude Opus 5 (1M context) --- .../appointments/ResourceDayPanel.tsx | 99 +++++++++++++++++++ assets/admin/pages/AppointmentsPage.test.tsx | 34 +++++++ assets/admin/pages/AppointmentsPage.tsx | 34 ++++--- 3 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 assets/admin/components/appointments/ResourceDayPanel.tsx diff --git a/assets/admin/components/appointments/ResourceDayPanel.tsx b/assets/admin/components/appointments/ResourceDayPanel.tsx new file mode 100644 index 00000000..51b44ad1 --- /dev/null +++ b/assets/admin/components/appointments/ResourceDayPanel.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import { formatNumber } from '../../lib/utils'; +import StatusBadge from '../ui/StatusBadge'; +import type { Appointment, ClinicResource } from '../../types'; + +function hhmm(ts: number): string { + const d = new Date(ts * 1000); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +/** + * نمای روزِ یک منبع. + * + * عمداً تایم‌لاین اسلاتی نیست: منبع برنامهٔ هفتگیِ اسلات‌شده ندارد، تقویمش از مدتِ + * سرویس‌ها و اشغالِ واقعی ساخته می‌شود. نشان دادن شبکهٔ اسلات برای منبع یعنی وعدهٔ + * زمان‌هایی که موتور رزرو اصلاً نمی‌شناسد. + * + * پس فقط دو چیز: آنچه امروز روی این منبع رزرو شده، و یک راه برای افزودن نوبتِ سرویسی. + */ +export default function ResourceDayPanel({ + resource, appointments, loading, canCreate, onBook, onView, +}: { + resource: ClinicResource; + appointments: Appointment[]; + loading: boolean; + canCreate: boolean; + onBook: () => void; + onView: (appointment: Appointment) => void; +}) { + const rows = [...appointments].sort((a, b) => a.slot_start - b.slot_start); + + return ( +
+
+ + نوبت‌دهی این منبع سرویسی است — زمان از مدت سرویس و آزادبودن خودِ منبع می‌آید، نه از اسلات ثابت. + + {resource.supervisor && ( + + زیر نظر {resource.supervisor.name} + + )} +
+ + {canCreate && ( + + )} + + {loading ? ( +
+ ) : rows.length === 0 ? ( +

+ برای «{resource.name}» در این روز نوبتی ثبت نشده است. +

+ ) : ( +
+ {rows.map((a) => ( + + ))} +
+ )} + + {rows.length > 0 && ( +

{formatNumber(rows.length)} نوبت روی این منبع در این روز

+ )} +
+ ); +} diff --git a/assets/admin/pages/AppointmentsPage.test.tsx b/assets/admin/pages/AppointmentsPage.test.tsx index 826d0ede..5eb6ca4c 100644 --- a/assets/admin/pages/AppointmentsPage.test.tsx +++ b/assets/admin/pages/AppointmentsPage.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../test/utils'; vi.mock('../lib/api', () => ({ @@ -121,6 +122,39 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () expect(screen.queryByText('دکتر بی‌برنامه')).toBeNull(); }); + /** + * باگ گزارش‌شده: انتخاب منبع، پزشک را پاک می‌کرد. افکتِ «انتخاب خودکار اولین پزشک» + * بلافاصله پزشکی را برمی‌گرداند، ولی **اولین** پزشک را — پس کاربری که روی پزشک دوم + * بود به پزشک اول می‌پرید و نوبت‌دهی همان پزشک را از دست می‌داد. + */ + it('انتخاب منبع، کاربر را روی همان پزشک نگه می‌دارد', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: 'دکتر رضایی' })); + const own = await screen.findByRole('button', { name: 'لیزر دکتر رضایی' }); + await user.click(own); + + // هنوز منابعِ پزشک دوم فهرست می‌شوند، نه منابع پزشک اول. + expect(await screen.findByRole('button', { name: 'لیزر دکتر رضایی' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'لیزر CO2' })).toBeNull(); + }); + + /** باگ گزارش‌شده: نمای منبع نباید اسلاتی باشد. */ + it('نمای منبع سرویسی است و تایم‌لاین اسلاتی ندارد', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText('دکتر محمدی'); + await user.click(await screen.findByRole('button', { name: 'لیزر CO2' })); + + expect(await screen.findByText(/نوبت‌دهی این منبع سرویسی است/)).toBeInTheDocument(); + expect(screen.getByText('افزودن نوبت سرویس')).toBeInTheDocument(); + // ورودی اسلاتی نباید باشد. + expect(screen.queryByText('افزودن نوبت سریع')).toBeNull(); + expect(screen.queryByText('این روز تعطیل است')).toBeNull(); + }); + it('auto-selects the first doctor so the timeline loads its slots', async () => { renderWithProviders(); await screen.findByText('دکتر محمدی'); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index fccb3669..787c6dea 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -24,10 +24,9 @@ import TurnsStatInfo from '../components/appointments/TurnsStatInfo'; import TurnsViewToggle from '../components/appointments/TurnsViewToggle'; import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle'; import DoctorTabs from '../components/appointments/DoctorTabs'; -/** هیچ تبی فعال نیست — وقتی تب منبع انتخاب شده، نوار پزشکان نباید هایلایت داشته باشد. */ -const NO_ACTIVE_TAB = '\u0000'; import { useResources } from '../hooks/useResources'; import ResourceBookingModal from '../components/appointments/ResourceBookingModal'; +import ResourceDayPanel from '../components/appointments/ResourceDayPanel'; import { useUrlState } from '../hooks/useUrlState'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; @@ -533,20 +532,20 @@ export default function AppointmentsPage() { * وقتی خودِ تب منبع فعال است `selectedDoctorUuid` خالی می‌شود، پس ناظرِ همان منبع * مبنا قرار می‌گیرد — وگرنه نوار منابع زیر پای کاربر خالی می‌شد. */ - const supervisorFilterUuid = selectedDoctorUuid - || bookableResources.find((r) => r.uuid === selectedResourceUuid)?.supervisor?.uuid - || ''; - const supervisedResources = supervisorFilterUuid - ? bookableResources.filter((r) => r.supervisor?.uuid === supervisorFilterUuid) + const supervisedResources = selectedDoctorUuid + ? bookableResources.filter((r) => r.supervisor?.uuid === selectedDoctorUuid) : []; const [bookingResource, setBookingResource] = useState(null); const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null; - const selectResource = (uuid: string) => { - setUrlState({ resource: uuid }); - if (uuid) setSelectedDoctorUuid(''); - }; + /** + * منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک + * تنگ می‌کند. پاک کردن پزشک، نوبت‌دهی خودِ او را از دسترس خارج می‌کرد. + */ + const selectResource = (uuid: string) => setUrlState({ resource: uuid }); + + /** برگشت به خودِ پزشک: نمای منبع بسته می‌شود. */ const selectDoctor = (uuid: string) => { setSelectedDoctorUuid(uuid); if (selectedResourceUuid) setUrlState({ resource: '' }); @@ -879,7 +878,7 @@ export default function AppointmentsPage() { {showDoctorTabs && ( @@ -915,6 +914,17 @@ export default function AppointmentsPage() {
)} + ) : activeResource ? ( + /* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاین اسلاتیِ پزشک اینجا + اصلاً رندر نمی‌شود — فهرست نوبت‌های همین منبع و یک ورودیِ سرویسی. */ + setBookingResource(activeResource)} + onView={openDetail} + /> ) : ( <> {!selectedDoctorUuid ? (