fix(appointments): a resource is a view inside its doctor, and never slot-based

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-03 12:58:13 +03:30
co-authored by Claude Opus 5
parent 202be9a328
commit 732fbd462f
3 changed files with 155 additions and 12 deletions
@@ -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 (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '8px 12px', marginBottom: 12, borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)', border: '1px solid var(--border)',
fontSize: 12.5, color: 'var(--text-2)',
}}>
<span>
نوبتدهی این منبع سرویسی است زمان از مدت سرویس و آزادبودن خودِ منبع میآید، نه از اسلات ثابت.
</span>
{resource.supervisor && (
<span style={{ color: 'var(--text-3)', flexShrink: 0 }}>
زیر نظر {resource.supervisor.name}
</span>
)}
</div>
{canCreate && (
<button
type="button"
className="btn primary"
onClick={onBook}
style={{ marginBottom: 14 }}
>
<PlusIcon style={{ width: 16 }} /> افزودن نوبت سرویس
</button>
)}
{loading ? (
<div className="skeleton" style={{ height: 90, borderRadius: 'var(--r-sm)' }} />
) : rows.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 2 }}>
برای «{resource.name}» در این روز نوبتی ثبت نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{rows.map((a) => (
<button
key={a.uuid}
type="button"
onClick={() => onView(a)}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%', textAlign: 'right',
padding: '10px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
border: '1px solid var(--border)', background: 'var(--surface)', fontFamily: 'inherit',
}}
>
<span dir="ltr" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', flexShrink: 0 }}>
{hhmm(a.slot_start)} {hhmm(a.slot_end)}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.patient_name || '—'}
{a.service_item?.name ? ` · ${a.service_item.name}` : ''}
</span>
<StatusBadge type="appointment" value={a.status} />
</button>
))}
</div>
)}
{rows.length > 0 && (
<p className="field-hint">{formatNumber(rows.length)} نوبت روی این منبع در این روز</p>
)}
</div>
);
}
@@ -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(<AppointmentsPage />);
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(<AppointmentsPage />);
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(<AppointmentsPage />);
await screen.findByText('دکتر محمدی');
+22 -12
View File
@@ -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<ClinicResource | null>(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 && (
<DoctorTabs
doctors={doctors}
selected={selectedResourceUuid ? NO_ACTIVE_TAB : selectedDoctorUuid}
selected={selectedDoctorUuid}
onSelect={selectDoctor}
showAll={isAdmin}
/>
@@ -915,6 +914,17 @@ export default function AppointmentsPage() {
</div>
)}
</>
) : activeResource ? (
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاین اسلاتیِ پزشک اینجا
اصلاً رندر نمی‌شود — فهرست نوبت‌های همین منبع و یک ورودیِ سرویسی. */
<ResourceDayPanel
resource={activeResource}
appointments={filteredAppointments}
loading={apptQuery.isLoading}
canCreate={!isRepresentation && canCreateAppt}
onBook={() => setBookingResource(activeResource)}
onView={openDetail}
/>
) : (
<>
{!selectedDoctorUuid ? (