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) => (
+ 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',
+ }}
+ >
+
+ {hhmm(a.slot_start)} – {hhmm(a.slot_end)}
+
+
+ {a.patient_name || '—'}
+ {a.service_item?.name ? ` · ${a.service_item.name}` : ''}
+
+
+
+ ))}
+
+ )}
+
+ {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 ? (