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>
This commit is contained in:
hamed
2026-07-16 14:20:32 +03:30
co-authored by Claude Opus 4.8
parent d7b06108f0
commit 07222826c3
14 changed files with 1462 additions and 550 deletions
+191
View File
@@ -0,0 +1,191 @@
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();
});
});