1. شارژ کیف پول is now functional end-to-end. New owner-gated
POST /api/v1/patient/{uuid}/wallet/charge creates a manual credit
WalletTransaction (computed balance_after); the patient detail's wallet tab
gains a top-up modal (PriceInput + description) and supports ?tab= deep
links. The deposit sections of the create drawer, the edit page and the
replace modal link to it via WalletChargeLink (record resolved by mobile).
2. جایگزینی نوبت now matches appointments-replace.pdf: patient search-or-new,
بخش/سرویس/پرسنل selects prefilled from the appointment, deposit toggle +
amount + charge link, read-only original date/time, status pick and notes —
all through the general PATCH.
3. The confirmed-appointments table is paginated (20/page, client-side so the
schedule view and doctor-tab derivation keep the whole day), resetting on
date/doctor/filter changes. The page-local STATUS_META also adopts the
design labels plus following_up/salon for the schedule cards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
151 lines
7.3 KiB
TypeScript
151 lines
7.3 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 PatientDetailPage from './PatientDetailPage';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
get.mockReset();
|
|
get.mockImplementation((url: string) => {
|
|
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
|
|
uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001',
|
|
user_mobile: '09120000000', user_national_code: '1234567890',
|
|
profile: { gender: 'female', referral_source: 'اینستاگرام', address: 'یزد', description: 'یادداشت' },
|
|
} });
|
|
if (url === '/api/v1/patient/r1/payments') return Promise.resolve({ success: true, data: [
|
|
{ uuid: 'p1', amount_rials: 250000, status: 'success', gateway: 'mellat', created_at: 1700000000 },
|
|
], meta: { totalRecords: 1 } });
|
|
if (url === '/api/v1/patient/r1/wallet') return Promise.resolve({ success: true, data: {
|
|
balance_rials: 300000,
|
|
recent_transactions: [{ uuid: 't1', amount_rials: 500000, type: 'credit', description: 'شارژ', balance_after: 300000, created_at: 1700000000 }],
|
|
} });
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
});
|
|
|
|
function renderDetail() {
|
|
return renderWithProviders(
|
|
<Routes>
|
|
<Route path="/admin/patients/:uuid" element={<PatientDetailPage />} />
|
|
</Routes>,
|
|
{ route: '/admin/patients/r1' },
|
|
);
|
|
}
|
|
|
|
describe('PatientDetailPage (پرونده تبدار)', () => {
|
|
it('renders the header and tab bar', async () => {
|
|
renderDetail();
|
|
expect(await screen.findByText('ساغر صابری')).toBeInTheDocument();
|
|
expect(screen.getByText('سرویسها')).toBeInTheDocument();
|
|
expect(screen.getByText('اطلاعات پرونده')).toBeInTheDocument();
|
|
expect(screen.getByText('پرونده پزشکی')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows patient info on the info tab', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری');
|
|
fireEvent.click(screen.getByText('اطلاعات پرونده'));
|
|
expect(screen.getByText('کد ملی')).toBeInTheDocument();
|
|
expect(screen.getByText('1234567890')).toBeInTheDocument();
|
|
expect(screen.getByText('اینستاگرام')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the call-center tab with a register form and history', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری');
|
|
fireEvent.click(screen.getByText('کال سنتر'));
|
|
expect(await screen.findByText('ثبت تماس جدید')).toBeInTheDocument();
|
|
expect(screen.getByText('تاریخچه تماسها')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('موضوع تماس')).toBeInTheDocument();
|
|
expect(await screen.findByText('تماسی ثبت نشده است.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the attachments tab with an upload button', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری');
|
|
fireEvent.click(screen.getByText('ضمیمه'));
|
|
expect(await screen.findByRole('button', { name: /آپلود فایل جدید/ })).toBeInTheDocument();
|
|
expect(await screen.findByText('هنوز فایلی ضمیمه نشده است.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('opens the add-exam modal on the medical-record tab', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری');
|
|
fireEvent.click(screen.getByText('پرونده پزشکی'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /ثبت معاینه جدید/ }));
|
|
// modal opens with a title field
|
|
expect(await screen.findByPlaceholderText('مثلاً: معاینه اولیه')).toBeInTheDocument();
|
|
});
|
|
|
|
it('links "سرویس جدید" on the services tab to the new-session route', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری'); // services is the default tab
|
|
const link = await screen.findByRole('link', { name: /سرویس جدید/ });
|
|
expect(link).toHaveAttribute('href', '/admin/patients/r1/session/new');
|
|
});
|
|
|
|
it('lists patient payments with status label on the payments tab', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری');
|
|
fireEvent.click(screen.getByText('پرداختها'));
|
|
expect(await screen.findByText('موفق')).toBeInTheDocument();
|
|
expect(screen.getByText(/mellat/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows wallet balance and a transaction on the wallet tab', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری');
|
|
fireEvent.click(screen.getByText('کیف پول'));
|
|
expect(await screen.findByText('موجودی کیف پول')).toBeInTheDocument();
|
|
expect(await screen.findByText('شارژ')).toBeInTheDocument();
|
|
});
|
|
|
|
it('opens the wallet tab directly via ?tab=wallet and offers a top-up', async () => {
|
|
renderWithProviders(
|
|
<Routes><Route path="/admin/patients/:uuid" element={<PatientDetailPage />} /></Routes>,
|
|
{ route: '/admin/patients/r1?tab=wallet' },
|
|
);
|
|
expect(await screen.findByText('موجودی کیف پول')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /شارژ کیف پول/ }));
|
|
expect(await screen.findByText('مبلغ شارژ (تومان)')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'ثبت شارژ' })).toBeDisabled();
|
|
});
|
|
|
|
it('posts the manual wallet charge', async () => {
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
post.mockResolvedValue({ success: true, data: { balance_rials: 800000, transaction: {} } });
|
|
renderWithProviders(
|
|
<Routes><Route path="/admin/patients/:uuid" element={<PatientDetailPage />} /></Routes>,
|
|
{ route: '/admin/patients/r1?tab=wallet' },
|
|
);
|
|
await screen.findByText('موجودی کیف پول');
|
|
fireEvent.click(screen.getByRole('button', { name: /شارژ کیف پول/ }));
|
|
fireEvent.change(await screen.findByPlaceholderText('مثلاً: بیعانه نوبت'), { target: { value: 'بیعانه' } });
|
|
// PriceInput displays toman; typing 30,000 toman = 300,000 rials
|
|
const priceInput = screen.getByText('مبلغ شارژ (تومان)').parentElement!.querySelector('input')!;
|
|
fireEvent.change(priceInput, { target: { value: '30000' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت شارژ' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', expect.objectContaining({
|
|
description: 'بیعانه',
|
|
})));
|
|
});
|
|
|
|
it('renders the messages tab with a send box', async () => {
|
|
renderDetail();
|
|
await screen.findByText('ساغر صابری');
|
|
fireEvent.click(screen.getByText('پیامها'));
|
|
expect(await screen.findByPlaceholderText('متن پیام...')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'ارسال' })).toBeInTheDocument();
|
|
expect(await screen.findByText('پیامی ثبت نشده است.')).toBeInTheDocument();
|
|
});
|
|
});
|