Files
clinicpro/assets/admin/components/resources/ResourceServicesModal.test.tsx
T
hamedandClaude Opus 5 26425bec31 Manage a resource's services from the panel
A "سرویس‌ها" action on each resource row opens a modal listing what that
resource performs, with its own duration and price. It follows the skills modal
exactly — same PUT-replaces-everything contract, same components, no new page
and no new route.

Leaving a cell empty means inherit, so the effective value is shown as the
placeholder along with where it came from: "40 — service default", "1,800,000
toman — branch". Without that the user cannot tell an unset field from a zero,
which is the one thing this screen has to communicate.

Two backend adjustments came out of wiring it up:

- the offering filter in findEligible is now scoped to the requirement's
  resource type. Registering lasers for a service was making the room in the
  same plan ineligible and breaking the whole booking — "who performs this" is
  about the performing role, not about rooms and support resources. The seeder
  caught this immediately.
- the scenario seeder now creates offerings and resource categories, so the
  demo data exercises this model instead of leaving every resource empty.

Three vitest tests: inherited value with its source, saving an override, and
clearing back to inheritance. Verified in the browser at 1440 dark, 1440
compact and 390 mobile — the last with no horizontal scroll.

Panel suite 648 green across 98 files, tsc clean, encore build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:57:55 +03:30

82 lines
2.9 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { screen } 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() } }));
import ResourceServicesModal from './ResourceServicesModal';
const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never;
const services = [
{ uuid: 's-1', name: 'لیزر پا', duration_minutes: 30, price_rials: 8_000_000 },
{ uuid: 's-2', name: 'لیزر دست', duration_minutes: 20, price_rials: 5_000_000 },
];
/** ردیفی که مدتش را خودِ منبع گفته ولی قیمتش را از سرویس ارث برده. */
const offerings = [
{
service_uuid: 's-1',
service_name: 'لیزر پا',
duration_minutes: 15,
price_rials: null,
active: true,
effective_duration_minutes: 15,
effective_price_rials: 8_000_000,
duration_source: 'resource_option',
price_source: 'service_default',
},
];
function props(overrides: Record<string, unknown> = {}) {
return {
resource,
offerings: offerings as never,
services: services as never,
saving: false,
onClose: vi.fn(),
onSave: vi.fn(),
...overrides,
};
}
describe('ResourceServicesModal', () => {
it('نشان می‌دهد مقدار ارث‌بری‌شده از کجا آمده', () => {
renderWithProviders(<ResourceServicesModal {...props()} />);
// مدت را خودِ منبع گفته، پس در خانه است.
expect(screen.getByDisplayValue('15')).toBeInTheDocument();
// قیمت تنظیم نشده: خانه خالی است و مقدار مؤثر با برچسبِ منبعش در placeholder.
const price = screen.getByPlaceholderText(/پیش‌فرض سرویس/);
expect(price).toHaveValue('');
});
it('override را ذخیره می‌کند', async () => {
const onSave = vi.fn();
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
const price = screen.getByPlaceholderText(/پیش‌فرض سرویس/);
await userEvent.type(price, '9500000');
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
expect(onSave).toHaveBeenCalledWith([
{ service_uuid: 's-1', duration_minutes: '15', price_rials: '9500000', active: true },
]);
});
it('پاک‌کردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
const onSave = vi.fn();
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
await userEvent.clear(screen.getByDisplayValue('15'));
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
// رشتهٔ خالی به سرور می‌رود و سرور آن را `null` می‌فهمد — نه `0`.
expect(onSave).toHaveBeenCalledWith([
{ service_uuid: 's-1', duration_minutes: '', price_rials: '', active: true },
]);
});
});