feat(settings): manage resource schedules beside the doctors'

A resource carries its own working hours and holidays in the resource-first
model, so it belongs on the same settings page as a doctor's schedule rather
than on a page of its own. The page now has a scope switch — doctors or
resources — with the per-item tab bar below it, and both scopes reuse the
panels that already existed: ScheduleSection for a doctor, the working-hours
and exceptions panels for a resource. The selection lives in the query
string, so back and refresh return to the same tab.

The screenshot of the finished tab caught two real defects, both fixed here:

Dates in the resource panels and the holidays page read as year 57932.
formatDate already multiplies seconds by 1000, and five call sites passed
`x * 1000` on top of it. This predates the tab — the code was inherited from
the old calendar page — but it was invisible until a two-week preview was put
on screen.

The working-hours panel still told the user their hours were intersected with
the branch's. Branches are gone; the shift is the only source now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 15:49:01 +03:30
co-authored by Claude Opus 5
parent f144401dd3
commit 9ba4c8d948
6 changed files with 286 additions and 63 deletions
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) }));
// ScheduleSection یک درختِ سنگین با کوئری‌های خودش است؛ اینجا فقط باید ثابت شود کدام
// scope رندر می‌شود، نه اینکه برنامهٔ پزشک درست کار می‌کند — آن تست خودش را دارد.
vi.mock('../components/schedule/ScheduleSection', () => ({
ScheduleSection: () => <div>برنامهٔ پزشک</div>,
}));
vi.mock('../components/FreeVisitPrice', () => ({ default: () => <div>قیمت ویزیت</div> }));
vi.mock('../components/resources/ResourceWorkingHoursPanel', () => ({
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>ساعات کاری {resourceUuid}</div>,
DAY_LABELS: [],
}));
vi.mock('../components/resources/ResourceExceptionsPanel', () => ({
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>تعطیلات {resourceUuid}</div>,
}));
let resources: Array<{ uuid: string; name: string; type_name: string }> = [];
vi.mock('../hooks/useResources', () => ({
useResources: () => ({ resources, loading: false }),
}));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import ClinicAppointmentSettingsPage from './ClinicAppointmentSettingsPage';
const get = api.get as ReturnType<typeof vi.fn>;
const LASER = { uuid: 'r-1', name: 'لیزر دایود', type_name: 'دستگاه لیزر' };
const ROOM = { uuid: 'r-2', name: 'اتاق ۱', type_name: 'اتاق درمان' };
beforeEach(() => {
vi.clearAllMocks();
resources = [LASER, ROOM];
useAuthStore.setState({
primaryRole: 'clinic',
dbUuid: 'c-1',
context: { type: 'clinic', db_uuid: 'c-1' } as never,
availableContexts: [],
});
get.mockResolvedValue({ success: true, data: { data: [{ uuid: 'd-1', name: 'دکتر مرادی' }] } });
});
describe('ClinicAppointmentSettingsPage', () => {
it('پیش‌فرض روی پزشکان است', async () => {
renderWithProviders(<ClinicAppointmentSettingsPage />);
await waitFor(() => expect(screen.getByText('برنامهٔ پزشک')).toBeInTheDocument());
expect(screen.queryByText(/ساعات کاری r-/)).not.toBeInTheDocument();
});
/** منبع همان‌جایی مدیریت می‌شود که برنامهٔ پزشک — نه در یک صفحهٔ جدا. */
it('تب منابع، ساعت کاری و تعطیلات منبع را می‌آورد', async () => {
const user = userEvent.setup();
renderWithProviders(<ClinicAppointmentSettingsPage />);
await user.click(screen.getByRole('button', { name: 'منابع' }));
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
expect(screen.getByText('تعطیلات r-1')).toBeInTheDocument();
expect(screen.queryByText('برنامهٔ پزشک')).not.toBeInTheDocument();
});
it('بین منابع جابه‌جا می‌شود', async () => {
const user = userEvent.setup();
renderWithProviders(<ClinicAppointmentSettingsPage />);
await user.click(screen.getByRole('button', { name: 'منابع' }));
await user.click(await screen.findByRole('button', { name: 'اتاق ۱' }));
await waitFor(() => expect(screen.getByText('ساعات کاری r-2')).toBeInTheDocument());
});
/** scope در URL می‌نشیند، وگرنه «بازگشت» و رفرش کاربر را به تب پزشکان می‌پراند. */
it('scope را از URL می‌خواند', async () => {
renderWithProviders(<ClinicAppointmentSettingsPage />, {
route: '/admin/settings/appointment-settings?scope=resources',
});
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
});
it('کلینیک بدون منبع، حالت خالی با راه‌حل می‌دهد', async () => {
resources = [];
const user = userEvent.setup();
renderWithProviders(<ClinicAppointmentSettingsPage />);
await user.click(screen.getByRole('button', { name: 'منابع' }));
expect(await screen.findByText('هنوز منبعی تعریف نشده است')).toBeInTheDocument();
expect(screen.getByText('تنظیمات ← منابع')).toHaveAttribute('href', '/admin/resources');
});
});