feat: convert deposit amounts from toman to rials in appointment handling

This commit is contained in:
hamed
2026-07-17 10:21:52 +03:30
parent a0ddb4c0d1
commit fcd7a0596d
9 changed files with 468 additions and 17 deletions
@@ -20,7 +20,7 @@ import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatRial, tehranWallClockToUnix } from "../lib/utils";
import { formatRial, tehranWallClockToUnix, rialToToman, tomanToRial } from "../lib/utils";
import type { Appointment } from "../types";
import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
import Modal from "./ui/Modal";
@@ -702,8 +702,8 @@ export function ReplaceAppointmentModal({
const [depositRequired, setDepositRequired] = useState(
!!a.deposit_required,
);
const [depositRials, setDepositRials] = useState(
a.deposit_amount_rials ?? 0,
const [depositToman, setDepositToman] = useState(
rialToToman(a.deposit_amount_rials ?? 0),
);
const [note, setNote] = useState("");
@@ -719,7 +719,7 @@ export function ReplaceAppointmentModal({
service_item_uuid: itemUuid,
staff_uuid: staffUuid,
deposit_required: depositRequired,
deposit_amount_rials: depositRequired ? depositRials : null,
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
...(note.trim() ? { note: note.trim() } : {}),
...(status !== a.status ? { status } : {}),
version: a.version,
@@ -904,8 +904,8 @@ export function ReplaceAppointmentModal({
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}>
<PriceInput
value={depositRials}
onChange={setDepositRials}
value={depositToman}
onChange={setDepositToman}
/>
</div>
</div>
@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
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 FreeVisitPrice from './FreeVisitPrice';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
const pricing = (priceRials: number, require: boolean) => ({
success: true,
data: { free_visit_price_rials: priceRials, require_visit_price: require },
});
beforeEach(() => {
get.mockReset();
put.mockReset();
put.mockResolvedValue({ success: true });
});
describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => {
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.click(screen.getByText('ذخیره'));
expect(await screen.findByText('با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است')).toBeInTheDocument();
expect(put).not.toHaveBeenCalled();
});
it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '50000' } });
fireEvent.click(screen.getByText('ذخیره'));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
free_visit_price_rials: 500_000,
require_visit_price: true,
}));
});
it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ می‌شود (ارسال مجاز)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
fireEvent.click(screen.getByText('ذخیره'));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
free_visit_price_rials: 0,
require_visit_price: false,
}));
});
it('فلگ ذخیره‌شده true → سوییچ روشن و ستاره روی label قیمت', async () => {
get.mockResolvedValue(pricing(500_000, true));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
expect(screen.getByRole('spinbutton')).toHaveValue(50_000);
});
});
@@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import { WalletChargeLink } from './AppointmentActions';
import { tehranWallClockToUnix } from '../lib/utils';
import { tehranWallClockToUnix, tomanToRial } from '../lib/utils';
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
@@ -104,7 +104,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
// ── deposit / status / notes ───────────────────────────────────────────────
const [depositRequired, setDepositRequired] = useState(false);
const [depositRials, setDepositRials] = useState(0);
const [depositToman, setDepositToman] = useState(0);
const [status, setStatus] = useState('pending');
const [note, setNote] = useState('');
@@ -135,7 +135,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
// حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری).
...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}),
...(note.trim() ? { note: note.trim() } : {}),
};
const res: any = await api.post('/api/v1/my/appointment', payload);
@@ -331,7 +331,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
<div style={{ flex: 1 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}>
<PriceInput value={depositRials} onChange={setDepositRials} />
<PriceInput value={depositToman} onChange={setDepositToman} />
</div>
</div>
<WalletChargeLink mobile={effectiveMobile} />
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
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 CreateStep from './CreateStep';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
function mockEndpoints(pricing: { free_visit_price_rials: number; require_visit_price: boolean }) {
get.mockImplementation((url: string) => {
if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: pricing });
if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } });
if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [] } });
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => {
get.mockReset();
post.mockReset();
post.mockResolvedValue({ success: true, data: { uuid: 's-1' } });
});
const renderStep = (onCreated = vi.fn()) => {
renderWithProviders(
<CreateStep recordUuid="r-1" profile={null} onCreated={onCreated} onCancel={() => {}} />,
);
return onCreated;
};
describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ require_visit_price', () => {
it('فلگ فعال + قیمت صفر → خطای inline، ستاره روی label و عدم ارسال', async () => {
mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true });
renderStep();
await waitFor(() => expect(screen.getByText(/قیمت ویزیت \(تومان\)/)).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument());
fireEvent.click(screen.getByText('ایجاد سرویس'));
expect(await screen.findByText('هزینه ویزیت الزامی است')).toBeInTheDocument();
expect(post).not.toHaveBeenCalled();
});
it('فلگ فعال + مقدار معتبر → POST با visit_price_rials ریالی', async () => {
mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true });
renderStep();
await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('قیمت ویزیت'), { target: { value: '50000' } });
fireEvent.click(screen.getByText('ایجاد سرویس'));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][0]).toBe('/api/v1/patient/r-1/session');
expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 500_000 });
});
it('فلگ غیرفعال + قیمت صفر → رفتار قبلی: ثبت مجاز', async () => {
mockEndpoints({ free_visit_price_rials: 0, require_visit_price: false });
const onCreated = renderStep();
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/insurance-pricing'));
fireEvent.click(screen.getByText('ایجاد سرویس'));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 0 });
await waitFor(() => expect(onCreated).toHaveBeenCalledWith('s-1'));
expect(screen.queryByText('هزینه ویزیت الزامی است')).not.toBeInTheDocument();
});
it('پیش‌فرض قیمت از «قیمت ویزیت آزاد» پر می‌شود (ریال → تومان)', async () => {
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
renderStep();
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(30_000));
});
});