An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
133 lines
6.6 KiB
TypeScript
133 lines
6.6 KiB
TypeScript
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('../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 AppointmentEditPage from './AppointmentEditPage';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
|
|
|
const slotStart = Math.floor(new Date('2026-08-01T15:00').getTime() / 1000);
|
|
const slotEnd = Math.floor(new Date('2026-08-01T16:00').getTime() / 1000);
|
|
|
|
beforeEach(() => {
|
|
get.mockReset(); patch.mockReset();
|
|
get.mockImplementation((url: string) => {
|
|
if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: {
|
|
uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4,
|
|
note: 'یادداشت', deposit_required: true, deposit_amount_rials: 5000000,
|
|
service_section: { uuid: 'sec1', name: 'زیبایی' }, service_item: { uuid: 'it1', name: 'لیزر' },
|
|
staff: { uuid: 'st1', full_name: 'سحر ایمانی' },
|
|
} } });
|
|
if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] });
|
|
if (url.startsWith('/api/v1/service-items/')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر' }] });
|
|
if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] });
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
patch.mockResolvedValue({ success: true, data: {} });
|
|
});
|
|
|
|
function renderEdit() {
|
|
return renderWithProviders(
|
|
<Routes><Route path="/admin/appointments/:uuid/edit" element={<AppointmentEditPage />} /></Routes>,
|
|
{ route: '/admin/appointments/ap1/edit' },
|
|
);
|
|
}
|
|
|
|
describe('AppointmentEditPage (ویرایش نوبت)', () => {
|
|
it('hydrates the form from the appointment', async () => {
|
|
renderEdit();
|
|
expect(await screen.findByText('مشخصات سرویس:')).toBeInTheDocument();
|
|
expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00');
|
|
expect(screen.getByText('قطعی شده')).toBeInTheDocument(); // react-select single value = confirmed
|
|
expect(screen.getByDisplayValue('یادداشت')).toBeInTheDocument();
|
|
});
|
|
|
|
it('patches the general update endpoint with the edited values', async () => {
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
fireEvent.change(screen.getByLabelText('ساعت پایان'), { target: { value: '16:30' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
|
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
|
|
slot_start: slotStart,
|
|
slot_end: Math.floor(new Date('2026-08-01T16:30').getTime() / 1000),
|
|
service_section_uuid: 'sec1',
|
|
staff_uuid: 'st1',
|
|
deposit_required: true,
|
|
version: 4,
|
|
})));
|
|
});
|
|
});
|
|
|
|
// ── بیمهٔ نوبت ────────────────────────────────────────────────────────────────
|
|
|
|
const CONTRACT = {
|
|
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
|
|
coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
|
|
category_coverages: { outpatient: 70, inpatient: 30 },
|
|
};
|
|
|
|
/** همان mock بالا + payload بیمه؛ `enabled` تعیین میکند چند نوع خدمت فعال است. */
|
|
function mockWithInsurance(enabledCategories: string[]) {
|
|
const categories = [
|
|
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: enabledCategories.includes('outpatient') },
|
|
{ key: 'inpatient', label: 'خدمات بستری', enabled: enabledCategories.includes('inpatient') },
|
|
];
|
|
get.mockImplementation((url: string) => {
|
|
if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: {
|
|
uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4,
|
|
visit_price_rials: 5_952_000, service_items: [],
|
|
insurance_service_category: 'inpatient', insurance_base_id: 3,
|
|
} } });
|
|
if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: {
|
|
service_categories: categories,
|
|
default_service_category: enabledCategories.length === 1 ? enabledCategories[0] : null,
|
|
} });
|
|
if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
}
|
|
|
|
describe('AppointmentEditPage — بیمه', () => {
|
|
it('نوع خدمت و بیمه از نوبت پیشپر میشوند و سهمها نمایش داده میشوند', async () => {
|
|
mockWithInsurance(['outpatient', 'inpatient']);
|
|
renderEdit();
|
|
|
|
expect(await screen.findByText('بیمه:')).toBeInTheDocument();
|
|
expect(screen.getByText('نوع خدمت')).toBeInTheDocument();
|
|
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
|
|
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
|
|
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ → سهم بیمه ۱٬۷۸۵٬۶۰۰ و سهم بیمار ۴٬۱۶۶٬۴۰۰
|
|
expect(screen.getByText('سهم بیمه / سهم بیمار')).toBeInTheDocument();
|
|
});
|
|
|
|
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
|
|
mockWithInsurance(['outpatient']);
|
|
renderEdit();
|
|
|
|
expect(await screen.findByText('بیمه:')).toBeInTheDocument();
|
|
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('PATCH فیلدهای بیمه را میفرستد', async () => {
|
|
mockWithInsurance(['outpatient', 'inpatient']);
|
|
renderEdit();
|
|
// تا فرم از نوبت پر نشود دکمه غیرفعال است؛ همان را معیار آمادهبودن میگیریم.
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).not.toBeDisabled());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
|
|
|
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
|
|
insurance_service_category: 'inpatient',
|
|
insurance_base_id: 3,
|
|
})));
|
|
});
|
|
});
|