Files
clinicpro/assets/admin/pages/NewSessionPage.test.tsx
T
hamedandClaude Opus 4.8 07222826c3 feat: port tauri create-service page as 3-step new-session wizard
Backend:
- Add session_at and inventory_package_id to patient_sessions, new
  session_consumables table (migration Version20260716102537)
- New SessionConsumable entity/repository mirroring SessionService;
  price snapshot, quantity >= 1, tenant-scoped silent skip
- PatientService::createSession accepts session_at, consumables[] and
  inventory_package_uuid; consumables are fully patient-paid (no
  insurance coverage) and added to final_price_rials
- Functional tests: success, foreign-tenant/unknown skip, empty and
  zero-quantity edges (tests/Patient/SessionConsumableTest.php)
- docs/api/patient.md updated for the new Create Session fields

Frontend (admin):
- NewSessionPage rewritten as the tauri /files/create-service 3-step
  wizard (ایجاد سرویس ← پرداخت ← جزییات) using SessionStepper
- New CreateStep: acceptance date/time (Jalali), section/service/staff,
  consumables with counters, package select, conditional insurance
  block (insured service or insured patient profile), price summary
- PaymentStep/DetailsStep extracted from SessionPaymentPage and shared
  between both pages (behavior unchanged, tests still green)
- UserTick and FilesServiceAddCard icons ported verbatim from tauri
- Vitest coverage for the wizard incl. empty-data states

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:20:32 +03:30

192 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
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 { formatRial } from '../lib/utils';
import NewSessionPage from './NewSessionPage';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const record = (profileOver: object = {}) => ({
uuid: 'r1',
user_name: 'ساغر صابری',
profile: { full_name: 'ساغر صابری', basic_insurance_id: null, supplementary_insurance_id: null, ...profileOver },
});
function mockGets(over: Partial<Record<string, unknown>> = {}) {
get.mockImplementation((url: string) => {
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: over.record ?? record() });
if (url === '/api/v1/service-sections') {
return Promise.resolve({ success: true, data: over.sections ?? [{ uuid: 'sec1', name: 'بخش زیبایی' }] });
}
if (url === '/api/v1/service-items/sec1') {
return Promise.resolve({
success: true,
data: over.items ?? [
{ uuid: 'svc1', name: 'کاشت مو', price_rials: 2_400_000, active: true, insurance_covered: false, staff: null },
],
});
}
if (url === '/api/v1/staff') {
return Promise.resolve({ success: true, data: over.staff ?? [{ uuid: 'st1', full_name: 'علی رضایی', active: true }] });
}
if (url === '/api/v1/inventory-items') {
return Promise.resolve({
success: true,
data: { items: over.inventory ?? [{ uuid: 'inv1', name: 'عینک', unit: 'عدد', price: 1_200_000, stock: 10, status: 'in_stock' }], stats: {} },
});
}
if (url === '/api/v1/inventory-packages') {
return Promise.resolve({ success: true, data: over.packages ?? [{ uuid: 'pkg1', title: 'پکیج شماره یک', total: 2_400_000, available: true }] });
}
if (url === '/api/v1/billing/tenant-insurances') {
return Promise.resolve({ success: true, data: { data: over.contracts ?? [] } });
}
if (url === '/api/v1/insurance-pricing') {
return Promise.resolve({ success: true, data: { free_visit_price_rials: 500_000 } });
}
if (url === '/api/v1/patient/r1/sessions') {
return Promise.resolve({ success: true, data: over.sessions ?? [] });
}
if (url === '/api/v1/patient/r1/wallet') {
return Promise.resolve({ success: true, data: { balance_rials: 300_000, recent_transactions: [] } });
}
return Promise.resolve({ success: true, data: [] });
});
}
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/patients/:recordUuid/session/new" element={<NewSessionPage />} />
</Routes>,
{ route: '/admin/patients/r1/session/new' },
);
}
/** انتخاب گزینه از SearchableSelect (react-select) با inputId */
async function pickOption(inputId: string, option: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.click(await screen.findByText(option));
}
beforeEach(() => {
get.mockReset();
post.mockReset();
});
describe('NewSessionPage (ویزارد ثبت مراجعه — پورت tauri create-service)', () => {
it('renders the 3-step stepper and step-1 fields (tauri Step1Details)', async () => {
mockGets();
renderPage();
// استپر سه گام («ایجاد سرویس» هم برچسب گام است هم دکمه ثبت)
expect((await screen.findAllByText('ایجاد سرویس')).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('پرداخت')).toBeInTheDocument();
expect(screen.getByText('جزییات')).toBeInTheDocument();
// فیلدهای گام ۱
expect(screen.getByText('تاریخ پذیرش')).toBeInTheDocument();
expect(screen.getByText('ساعت پذیرش')).toBeInTheDocument();
expect(screen.getByText('انتخاب بخش')).toBeInTheDocument();
expect(screen.getByText('انتخاب سرویس')).toBeInTheDocument();
expect(screen.getByText('پرسنل')).toBeInTheDocument();
expect(screen.getByText('انتخاب کالای مصرفی')).toBeInTheDocument();
expect(screen.getByText('انتخاب پکیج')).toBeInTheDocument();
// پذیرش‌کننده + breadcrumb بیمار
expect(screen.getByText(/پذیرش کننده:/)).toBeInTheDocument();
expect((await screen.findAllByText('ساغر صابری')).length).toBeGreaterThan(0);
// بدون سرویس بیمه‌دار و بدون بیمه‌ی پروفایل → بلوک بیمه پنهان
expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument();
});
it('shows the insurance block when patient profile has insurance', async () => {
mockGets({ record: record({ basic_insurance_id: 7 }) });
renderPage();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument();
});
it('creates the session with session_at, consumables, package and staff then moves to payment step', async () => {
mockGets({
sessions: [{
uuid: 's-new', services: [], visit_price_rials: 500_000, final_price_rials: 1_700_000,
patient_debt_rials: 1_700_000, discount_rials: 0, payments: [], is_paid: false,
}],
});
post.mockResolvedValue({ success: true, data: { uuid: 's-new' } });
renderPage();
await screen.findByText('انتخاب بخش');
// بخش → سرویس → افزودن
await pickOption('section-select', 'بخش زیبایی');
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/service-items/sec1'));
await pickOption('service-select', 'کاشت مو');
fireEvent.click(screen.getByLabelText('افزودن سرویس'));
expect(screen.getByText('خدمات انتخاب شده')).toBeInTheDocument();
// کالای مصرفی: عینک
await pickOption('consumable-select', 'عینک');
fireEvent.click(screen.getByLabelText('افزودن کالا'));
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(1_200_000)}`)).toBeInTheDocument();
// ثبت
fireEvent.click(screen.getByText('ایجاد سرویس', { selector: 'button' }));
await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
const [url, body] = post.mock.calls[0] as [string, any];
expect(url).toBe('/api/v1/patient/r1/session');
expect(typeof body.session_at).toBe('number');
expect(body.payment_method).toBe('pending');
expect(body.services).toEqual([{ service_item_uuid: 'svc1', quantity: 1 }]);
expect(body.consumables).toEqual([{ inventory_item_uuid: 'inv1', quantity: 1 }]);
// بدون بیمه → درصدها صفر و id ارسال نمی‌شود
expect(body.base_insurance_discount_percent).toBe(0);
expect(body.insurance_base_id).toBeUndefined();
// ورود به گام پرداخت با دیتای واقعی session ساخته‌شده
expect(await screen.findByText('هزینه سرویس:')).toBeInTheDocument();
expect(screen.getByText('موجودی کیف پول:')).toBeInTheDocument();
expect(screen.getAllByText(formatRial(1_700_000)).length).toBeGreaterThan(0);
});
it('increments consumable quantity via the counter and reflects the total', async () => {
mockGets();
renderPage();
await screen.findByText('انتخاب کالای مصرفی');
await pickOption('consumable-select', 'عینک');
fireEvent.click(screen.getByLabelText('افزودن کالا'));
fireEvent.click(screen.getByLabelText('افزایش'));
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(2_400_000)}`)).toBeInTheDocument();
// کاهش تا صفر → حذف
fireEvent.click(screen.getByLabelText('کاهش'));
fireEvent.click(screen.getByLabelText('کاهش'));
expect(screen.getByText('کالایی انتخاب نشده است')).toBeInTheDocument();
});
it('renders empty option lists without crashing (بدون بخش/کالا/پکیج)', async () => {
mockGets({ sections: [], inventory: [], packages: [], staff: [] });
renderPage();
expect(await screen.findByText('انتخاب بخش')).toBeInTheDocument();
expect(screen.getByText('کالایی انتخاب نشده است')).toBeInTheDocument();
// مجموع صفر
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(0)}`)).toBeInTheDocument();
});
});