feat(ConfirmAppointmentModal): add payment handling tests and improve modal functionality

This commit is contained in:
hamed
2026-07-19 10:49:30 +03:30
parent 357adb1d14
commit 088e1348b5
2 changed files with 181 additions and 32 deletions
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } 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 ConfirmAppointmentModal from './ConfirmAppointmentModal';
const appointment = {
uuid: 'a1',
version: 1,
patient_name: 'محمد رضایی',
visit_price_rials: 2_120_000,
service_items: [{ uuid: 's1', name: 'لیزر', price_rials: 880_000 }],
};
function render() {
return renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={appointment} onClose={() => {}} />,
);
}
beforeEach(() => vi.clearAllMocks());
describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => {
render();
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
expect(screen.getByText('ویزیت')).toBeInTheDocument();
expect(screen.getByText('لیزر')).toBeInTheDocument();
expect(screen.getByText('جمع کل')).toBeInTheDocument();
});
it('مبلغ پرداختی پیش‌فرض برابر باقی‌مانده (کل هزینه) است', () => {
render();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(screen.getByPlaceholderText('0')).toHaveValue('۳۰۰٬۰۰۰');
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
});
it('با تغییر دستی مبلغ، باقی‌مانده دوباره محاسبه می‌شود', () => {
render();
fireEvent.change(screen.getByPlaceholderText('0'), { target: { value: '100000' } });
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
// ۳۰۰٬۰۰۰ − ۱۰۰٬۰۰۰ تومان باقی‌مانده ⇒ ۲٬۰۰۰٬۰۰۰ ریال
expect(screen.getByText(/باقی‌مانده پس از این پرداخت/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'بدون پرداخت' }));
expect(screen.getByText('بدون پرداخت', { selector: 'span' })).toBeInTheDocument();
});
it('دکمهٔ تأیید فقط با مبلغ بیشتر از جمع کل غیرفعال می‌شود', () => {
render();
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
expect(submit).not.toBeDisabled();
// مبلغ بالاتر از جمع کل (۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان < کل؛ پس عدد بزرگ‌تر می‌دهیم)
fireEvent.change(screen.getByPlaceholderText('0'), { target: { value: '9000000' } });
expect(screen.getByText('مبلغ پرداخت از جمع کل بیشتر است.')).toBeInTheDocument();
expect(submit).toBeDisabled();
});
});
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { UserCircleIcon } from '@heroicons/react/24/outline';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
@@ -44,8 +45,17 @@ const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px 0',
borderBottom: '1px solid var(--border)',
gap: 12,
padding: '9px 0',
fontSize: 13.5,
color: 'var(--text-2)',
};
/** رنگ چیپِ «وضعیت پرداخت» بر اساس نسبت پرداخت به جمع کل. */
const STATE_TONE: Record<string, { fg: string; bg: string }> = {
'بدون پرداخت': { fg: 'var(--text-2)', bg: 'var(--surface-3)' },
'پرداخت جزئی': { fg: 'var(--warning)', bg: 'var(--warning-bg)' },
'تسویه کامل': { fg: 'var(--success)', bg: 'var(--success-bg)' },
};
/**
@@ -64,6 +74,8 @@ export default function ConfirmAppointmentModal({
const qc = useQueryClient();
const [method, setMethod] = useState('cash');
const [amountToman, setAmountToman] = useState(0);
/** تا وقتی کاربر مبلغ را دست نزده، فیلد با باقی‌ماندهٔ نوبت پر می‌ماند. */
const [touched, setTouched] = useState(false);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم:
// مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند.
@@ -84,6 +96,13 @@ export default function ConfirmAppointmentModal({
);
const total = visitPrice + servicesTotal;
// جمع کل تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛
// نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر کل هزینه است.
useEffect(() => {
if (!open || touched || total <= 0) return;
setAmountToman(rialToToman(total));
}, [open, touched, total]);
const amountRials = tomanToRial(amountToman);
const remaining = Math.max(0, total - amountRials);
const overpaid = amountRials > total;
@@ -114,6 +133,13 @@ export default function ConfirmAppointmentModal({
function reset() {
setAmountToman(0);
setMethod('cash');
setTouched(false);
}
/** تغییر دستی مبلغ: از این به بعد پیش‌فرضِ خودکار دیگر روی فیلد ننشیند. */
function changeAmount(next: number) {
setTouched(true);
setAmountToman(next);
}
function handleClose() {
@@ -131,7 +157,7 @@ export default function ConfirmAppointmentModal({
onClose={handleClose}
footer={
<>
<button type="button" className="btn" onClick={handleClose}>
<button type="button" className="btn ghost" onClick={handleClose}>
انصراف
</button>
<button
@@ -150,67 +176,124 @@ export default function ConfirmAppointmentModal({
) : (
<>
{appt?.patient_name && (
<p style={{ marginBottom: 12, color: 'var(--text-2)' }}>بیمار: {appt.patient_name}</p>
<div
style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16,
padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-soft)',
}}
>
<UserCircleIcon style={{ width: 20, height: 20, color: 'var(--primary-700)', flexShrink: 0 }} />
<span style={{ fontSize: 13.5, color: 'var(--text-2)' }}>بیمار</span>
<strong style={{ fontSize: 14, color: 'var(--primary-700)' }}>{appt.patient_name}</strong>
</div>
)}
<div style={{ marginBottom: 18 }}>
{/* هزینه‌ها */}
<div
style={{
border: '1px solid var(--border)', borderRadius: 'var(--r)',
padding: '4px 14px 10px', marginBottom: 20,
}}
>
<div style={rowStyle}>
<span>ویزیت</span>
<strong>{formatRial(visitPrice)}</strong>
<strong style={{ color: 'var(--text)' }}>{formatRial(visitPrice)}</strong>
</div>
{services.map((s) => (
<div key={s.uuid} style={rowStyle}>
<div key={s.uuid} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>{s.name}</span>
<strong>{formatRial(Number(s.price_rials ?? 0))}</strong>
<strong style={{ color: 'var(--text)' }}>{formatRial(Number(s.price_rials ?? 0))}</strong>
</div>
))}
<div style={{ ...rowStyle, borderBottom: 'none', fontSize: 16 }}>
<div
style={{
...rowStyle, borderTop: '1px solid var(--border)', marginTop: 2,
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
}}
>
<span>جمع کل</span>
<strong>{formatRial(total)}</strong>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(total)}</strong>
</div>
</div>
<div className="field" style={{ marginBottom: 12 }}>
<label>روش پرداخت</label>
<SearchableSelect
value={method}
onChange={(v) => setMethod(String(v ?? 'cash'))}
options={METHOD_OPTIONS}
placeholder="روش پرداخت"
/>
{/* پرداخت */}
<div
style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 14, marginBottom: 10,
}}
>
<div className="field-block">
<label>روش پرداخت</label>
<SearchableSelect
value={method}
onChange={(v) => setMethod(String(v ?? 'cash'))}
options={METHOD_OPTIONS}
placeholder="روش پرداخت"
height={40}
/>
</div>
<div className="field-block">
<label>مبلغ پرداختی (تومان)</label>
<div className="field" style={overpaid ? { borderColor: 'var(--danger)' } : undefined}>
<PriceInput value={amountToman} onChange={changeAmount} suffix="تومان" max={rialToToman(total)} />
</div>
<span className="field-hint">باقیمانده پس از این پرداخت: {formatRial(remaining)}</span>
</div>
</div>
<div className="field" style={{ marginBottom: 12 }}>
<label>مبلغ پرداختی (تومان)</label>
<PriceInput value={amountToman} onChange={setAmountToman} suffix="تومان" />
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<button
type="button"
className="btn sm"
style={{ marginTop: 8 }}
onClick={() => setAmountToman(rialToToman(total))}
className="btn soft sm"
onClick={() => changeAmount(rialToToman(total))}
>
پرداخت کامل
</button>
{amountToman > 0 && (
<button type="button" className="btn ghost sm" onClick={() => changeAmount(0)}>
بدون پرداخت
</button>
)}
</div>
{overpaid && (
<p style={{ color: 'var(--danger)', marginBottom: 12 }}>
<p className="field-err" style={{ marginBottom: 14 }}>
مبلغ پرداخت از جمع کل بیشتر است.
</p>
)}
<div style={{ background: 'var(--surface-2)', borderRadius: 'var(--r-sm)', padding: 12 }}>
{/* خلاصه */}
<div
style={{
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: '4px 14px 10px',
}}
>
<div style={rowStyle}>
<span>پرداختشده</span>
<strong>{formatRial(Math.min(amountRials, total))}</strong>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(amountRials, total))}</strong>
</div>
<div style={rowStyle}>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>باقیمانده</span>
<strong>{formatRial(remaining)}</strong>
<strong style={{ color: remaining > 0 ? 'var(--danger)' : 'var(--success)' }}>
{formatRial(remaining)}
</strong>
</div>
<div style={{ ...rowStyle, borderBottom: 'none' }}>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>وضعیت پرداخت</span>
<strong>{paymentState}</strong>
<span
style={{
padding: '4px 12px', borderRadius: 'var(--r-pill)',
fontSize: 12.5, fontWeight: 700,
color: STATE_TONE[paymentState].fg,
background: STATE_TONE[paymentState].bg,
}}
>
{paymentState}
</span>
</div>
</div>
</>