test(schedule): add tests for booking mode changes and resource strategy removal

This commit is contained in:
hamed
2026-08-04 10:35:49 +03:30
parent 41b3109478
commit 1db1ee4cd8
2 changed files with 104 additions and 116 deletions
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error { status = 500; },
}));
import { api } from '../../lib/api';
import { WeeklyScheduleTab } from './ScheduleSection';
import type { AddressData } from './ScheduleSection';
const get = api.get as ReturnType<typeof vi.fn>;
/** بدون حداقل یک مکان، تب اصلاً فرم را رندر نمی‌کند. */
const ADDRESSES: AddressData[] = [{
id: '1', uuid: 'addr-1', type: 'personal', clinic_id: null, clinic_name: null,
name: 'مطب', address: 'تهران', telephone: null,
map: { latitude: null, longitude: null }, city: null, province: null,
}];
/** پاسخ برنامهٔ هفتگی؛ فقط `meta` بین تست‌ها فرق می‌کند. */
function mockSchedule(meta: Record<string, unknown>, locked = false) {
get.mockImplementation((url?: string) => {
if (typeof url === 'string' && url.includes('weekly-schedule')) {
return Promise.resolve({
success: true,
data: {
uuid: 'sch-1',
doctor_uuid: 'd1',
schedule: {},
meta: { online_booking_enabled: true, booking_window_value: 3, booking_window_unit: 'month', buffer_minutes: 0, ...meta },
booking_mode_locked: locked,
},
});
}
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => get.mockReset());
describe('WeeklyScheduleTab — روش نوبت‌دهی', () => {
/**
* نوبت‌دهی منبع‌محور از تنظیمات پزشک برداشته شده: انتخابش برگشت‌ناپذیر بود و
* منابع مسیر نوبت‌دهی مستقل خودشان را دارند.
*/
it('گزینهٔ «نوبت‌دهی منبع‌محور» دیگر قابل انتخاب نیست', async () => {
mockSchedule({ booking_mode: 'slot' });
renderWithProviders(<WeeklyScheduleTab doctorUuid="d1" addresses={ADDRESSES} />);
expect(await screen.findByText('نوبت‌دهی اسلاتی')).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی سرویسی')).toBeInTheDocument();
expect(screen.queryByText('نوبت‌دهی منبع‌محور')).toBeNull();
// فیلدهای مخصوص همان حالت هم رفته‌اند.
expect(screen.queryByText('گام جستجوی وقت')).toBeNull();
expect(screen.queryByText('ترتیب انتخاب منابع')).toBeNull();
});
it('استراتژی‌ها و شرط‌های آمادگیِ منبع‌محور دیگر درخواست نمی‌شوند', async () => {
mockSchedule({ booking_mode: 'slot' });
renderWithProviders(<WeeklyScheduleTab doctorUuid="d1" addresses={ADDRESSES} />);
await screen.findByText('نوبت‌دهی اسلاتی');
const urls = get.mock.calls.map((c: unknown[]) => c[0]).filter((u): u is string => typeof u === 'string');
expect(urls.some(u => u.includes('resource-strategies'))).toBe(false);
expect(urls.some(u => u.includes('/api/v1/service-items'))).toBe(false);
});
/** رکورد قدیمیِ منبع‌محور باید روشِ ثبت‌شده‌اش را بگوید، نه اینکه بی‌حالت دیده شود. */
it('رکورد قدیمیِ منبع‌محور فقط به‌صورت خواندنی اعلام می‌شود', async () => {
mockSchedule({ booking_mode: 'resource' }, true);
renderWithProviders(<WeeklyScheduleTab doctorUuid="d1" addresses={ADDRESSES} />);
expect(await screen.findByText(/روش ثبت‌شدهٔ این پزشک/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /نوبت‌دهی منبع‌محور/ })).toBeNull();
});
});
@@ -60,10 +60,14 @@ interface BookingMeta {
online_booking_enabled: boolean;
booking_window_value: number;
booking_window_unit: BookingWindowUnit;
/**
* `resource` دیگر قابل انتخاب نیست و فقط برای خواندنِ رکوردهای قدیمی در نوع می‌ماند
* — سرور همچنان همان مقدار را برمی‌گرداند.
*/
booking_mode: 'slot' | 'service' | 'resource';
/** گام جستجوی وقت در حالت منبع‌محور — دقیقه */
/** فقط پس‌فرست می‌شود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */
step_minutes?: number;
/** ترتیب امتحان‌کردن منابع در حالت منبع‌محور */
/** فقط پس‌فرست می‌شود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */
resource_strategy?: string;
buffer_minutes: number;
}
@@ -80,6 +84,16 @@ const MODE_LABELS: Record<BookingMeta['booking_mode'], string> = {
resource: 'نوبت‌دهی منبع‌محور',
};
/**
* حالت‌های قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: نوبت‌دهی منبع‌محور از
* تنظیمات پزشک برداشته شده و مسیر خودش (صفحهٔ «نوبت منبع‌محور» و تقویم هر منبع) را
* دارد. انتخابش برگشت‌ناپذیر بود و محیط را قفل می‌کرد.
*/
const BOOKING_MODE_CARDS: ReadonlyArray<readonly [BookingMeta['booking_mode'], string, string]> = [
['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'],
['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'],
];
const DEFAULT_BOOKING_META: BookingMeta = {
online_booking_enabled: true,
booking_window_value: 3,
@@ -555,44 +569,6 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
const [modeLocked, setModeLocked] = useState(false);
/**
* شرط‌های آمادگیِ حالت منبع‌محور.
*
* فقط وقتی پرسیده می‌شود که کاربر واقعاً همان حالت را انتخاب کرده باشد — دو
* درخواست اضافه روی هر بازکردن تنظیمات، برای چیزی که اکثر کلینیک‌ها انتخابش
* نمی‌کنند، هزینهٔ بی‌دلیل است.
*/
/** فهرست استراتژی‌ها از سرور می‌آید، نه از فهرستی که در فرانت تکرار شود. */
const strategiesQ = useQuery({
queryKey: ['resource-strategies'],
queryFn: () =>
api.get<ApiResponse<{ code: string; label: string }[]>>(
'/api/v1/appointment-settings/resource-strategies',
),
enabled: meta.booking_mode === 'resource',
staleTime: 300_000,
});
const strategies = strategiesQ.data?.data ?? [];
const readinessQ = useQuery({
queryKey: ['resource-mode-readiness'],
queryFn: async () => {
const [resources, services] = await Promise.all([
api.get<ApiResponse<{ uuid: string }[]>>('/api/v1/resources'),
api.get<ApiResponse<{ uuid: string; has_segments?: boolean }[]>>('/api/v1/service-items'),
]);
return {
hasResources: (resources.data ?? []).length > 0,
hasSegments: (services.data ?? []).some((s) => s.has_segments === true),
};
},
enabled: meta.booking_mode === 'resource' && !modeLocked,
staleTime: 30_000,
});
const resourceReadiness = readinessQ.data ?? { hasResources: false, hasSegments: false };
const [confirmMode, setConfirmMode] = useState(false);
const scheduleQ = useQuery({
@@ -742,12 +718,16 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<span className="text-sm font-medium text-[var(--text)]">روش نوبتدهی</span>
</div>
<div className="px-4 py-3 space-y-3">
{/* رکوردهای قدیمیِ منبع‌محور هنوز در پایگاه‌داده هستند؛ بدون این، کارتی
انتخاب‌شده دیده نمی‌شد و صفحه «بی‌حالت» به نظر می‌رسید. */}
{meta.booking_mode === 'resource' && (
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
روش ثبتشدهٔ این پزشک «{MODE_LABELS.resource}» است؛ این روش دیگر قابل انتخاب نیست.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{([
['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'],
['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'],
['resource', 'نوبت‌دهی منبع‌محور', 'نوبت به چند بخش تقسیم می‌شود و هر بخش منابع خودش (اتاق، اپراتور، دستگاه) را می‌گیرد؛ وقت آزاد از تقاطع تقویم همان منابع می‌آید. مناسب کلینیک زیبایی و لیزر.'],
] as const).map(([val, lbl, desc]) => {
{BOOKING_MODE_CARDS.map(([val, lbl, desc]) => {
const selected = meta.booking_mode === val;
return (
<button
@@ -780,77 +760,6 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
</p>
)}
{meta.booking_mode === 'resource' && !modeLocked && (
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3 space-y-2">
<span className="text-xs font-medium text-[var(--text)]">
پیش از انتخاب این حالت، اینها باید آماده باشند:
</span>
{/* انتخاب برگشت‌ناپذیر است، پس شرط‌ها باید **قبل** از ثبت دیده شوند نه در قالب خطای ۴۲۲ بعدش. */}
<ul className="space-y-1.5">
{[
['حداقل یک منبع فعال (اتاق، اپراتور یا دستگاه)', resourceReadiness.hasResources],
['حداقل یک سرویس با بخش‌های تعریف‌شده', resourceReadiness.hasSegments],
].map(([label, ok]) => (
<li key={String(label)} className="flex items-center gap-2 text-xs">
<span className={ok ? 'text-[var(--success)]' : 'text-[var(--danger)]'}>
{ok ? '✓' : '✗'}
</span>
<span className="text-[var(--text-2)]">{label}</span>
{!ok && (
<a href="/admin/resources" className="text-[var(--primary)] underline">
تعریف کنید
</a>
)}
</li>
))}
</ul>
{!resourceReadiness.hasResources && (
<p className="text-xs text-[var(--danger)]">
بدون منبع فعال، هیچ وقتی محاسبه نمیشود و چون این انتخاب برگشتناپذیر است، محیط قفل میماند.
</p>
)}
</div>
)}
{meta.booking_mode === 'resource' && strategies.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm text-[var(--text-2)]">ترتیب انتخاب منابع</span>
<div className="max-w-md">
<GlobalSearchableSelect
value={meta.resource_strategy ?? 'first_available'}
onChange={(v) =>
setMeta(m => ({ ...m, resource_strategy: String(v ?? 'first_available') }))
}
options={strategies.map(s => ({ value: s.code, label: s.label }))}
/>
</div>
<p className="text-xs text-[var(--text-3)] leading-relaxed">
این فقط ترتیب امتحانکردن منابع را عوض میکند؛ اگر منبعی آزاد نباشد در هر
حالت رد میشود و وقت از دست نمیرود.
</p>
</div>
)}
{meta.booking_mode === 'resource' && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--text-2)]">گام جستجوی وقت</span>
<input
type="text"
inputMode="numeric"
dir="ltr"
value={meta.step_minutes ?? 15}
onChange={(e) =>
setMeta(m => ({ ...m, step_minutes: Math.max(5, Number(digitsOnly(e.target.value)) || 5) }))
}
className="w-16 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] px-2 py-1.5 focus:outline-none focus:ring-0"
/>
<span className="text-sm text-[var(--text-2)]">دقیقه</span>
<p className="w-full text-xs text-[var(--text-3)] leading-relaxed">
گام کوچکتر وقتهای بیشتری پیدا میکند ولی جستجو را کندتر میکند؛ پنج دقیقه کمترین مقدار مجاز است.
</p>
</div>
)}
{meta.booking_mode === 'service' ? (
<>
<div className="flex flex-wrap items-center gap-2">