feat: update session payment logic to ensure accurate payable amounts and reflect consumables in cost breakdown
- Adjusted the calculation of payable amounts in PaymentStep to align with server logic, ensuring overpayments are handled correctly. - Enhanced DetailsStep to include consumables in the itemized cost breakdown, ensuring consistency with patient share calculations. - Updated tests for SessionPaymentPage to validate new behavior regarding overpayments and consumable listings. - Modified PatientController to register SessionPayment correctly when settling sessions via wallet, preventing double charges. - Refactored WalletService to remove outdated methods and ensure wallet transactions reflect the correct amounts after discounts. - Improved accessibility in SearchableSelect component by adding aria labels and ensuring proper role attributes for screen readers. - Updated styles to ensure minimum touch targets meet WCAG guidelines for mobile usability.
This commit is contained in:
@@ -22,6 +22,8 @@ const fullSession = {
|
||||
session_at: 1700000000, paid_at: 1700100000,
|
||||
services_total_rials: 2_400_000, consumables_total_rials: 40_000,
|
||||
discount_rials: 200_000, final_price_rials: 2_240_000, paid_total_rials: 1_500_000,
|
||||
// API واقعی همیشه این را میفرستد: max(0, final − discount − paid)
|
||||
remaining_rials: 540_000,
|
||||
payments: [
|
||||
{ uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' },
|
||||
],
|
||||
@@ -59,9 +61,10 @@ describe('InvoiceSummaryModal', () => {
|
||||
expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument();
|
||||
|
||||
// وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰):
|
||||
// پرداختشده ۱۵۰٬۰۰۰ (هم در جدول پرداختیها هم وضعیت) و باقیمانده ۷۴٬۰۰۰
|
||||
// پرداختشده ۱۵۰٬۰۰۰ (هم در جدول پرداختیها هم وضعیت) و باقیمانده ۵۴٬۰۰۰
|
||||
// (۲۲۴۰۰۰۰ − ۲۰۰۰۰۰ تخفیف − ۱۵۰۰۰۰۰ پرداختی = ۵۴۰۰۰۰ ریال؛ final_price پیش از تخفیف است)
|
||||
expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText(/۷۴٬۰۰۰/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/۵۴٬۰۰۰/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ میشود', async () => {
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface SessionPaymentEntry {
|
||||
export interface SessionCardData {
|
||||
uuid: string;
|
||||
services?: Array<{ service_item_uuid?: string; service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>;
|
||||
consumables?: Array<{ inventory_item_uuid?: string; item_name?: string; price_rials?: number; quantity?: number }>;
|
||||
// مطابق SessionConsumable::toArray در بکاند
|
||||
consumables?: Array<{ uuid?: string; inventory_item_uuid?: string; item_name?: string; unit?: string; price_rials?: number; quantity?: number; line_total_rials?: number }>;
|
||||
visit_price_rials?: number;
|
||||
services_total_rials?: number;
|
||||
session_at?: number | null;
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function DoctorTabs({
|
||||
position: 'relative', background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 15, fontWeight: 500, padding: '8px 2px 12px',
|
||||
color: active ? 'var(--primary)' : 'var(--text-2)', whiteSpace: 'nowrap',
|
||||
// برچسبهای کوتاه («همه») بدون این، عرضشان زیر ۴۴px میماند (WCAG 2.5.5)
|
||||
minWidth: 44,
|
||||
}}
|
||||
>
|
||||
{d.name}
|
||||
|
||||
@@ -58,7 +58,9 @@ describe('SettingsLayout', () => {
|
||||
// SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU
|
||||
expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic-doctors');
|
||||
expect(menuForRole('clinic').map((i) => i.key)).not.toContain('doctor');
|
||||
expect(menuForRole('clinic').map((i) => i.key)).not.toContain('appointment');
|
||||
// کلینیک ورودی تنظیمات نوبتدهیِ مخصوص خودش را دارد
|
||||
expect(menuForRole('clinic').find((i) => i.key === 'appointment')?.to)
|
||||
.toBe('/admin/settings/appointment-settings');
|
||||
// a plain doctor must not get the clinic-doctors management tab
|
||||
expect(menuForRole('doctor').map((i) => i.key)).not.toContain('clinic-doctors');
|
||||
});
|
||||
|
||||
@@ -41,7 +41,9 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
|
||||
// زمان مراجعه: زمان واقعی نوبت؛ در نبود آن، زمان ثبت پرونده.
|
||||
const visitAt = session.session_at ?? session.created_at ?? null;
|
||||
|
||||
// آیتمهای هزینهی تفکیکشده: ویزیت + هر سرویس، با جمع کل.
|
||||
// آیتمهای هزینهی تفکیکشده: ویزیت + هر سرویس + کالای مصرفی.
|
||||
// کالای مصرفی هم باید بیاید چون سرور آن را در سهم بیمار میآورد
|
||||
// (PatientService::…$consumablesTotal)؛ نبودش ریز هزینهها را با جمع ناسازگار میکرد.
|
||||
const costItems: Array<{ label: string; rials: number }> = [];
|
||||
if (visitPrice > 0) costItems.push({ label: 'ویزیت', rials: visitPrice });
|
||||
(session.services ?? []).forEach((s) => {
|
||||
@@ -50,6 +52,19 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
|
||||
rials: s.line_total_rials ?? (s.price_rials ?? 0) * (s.quantity ?? 1),
|
||||
});
|
||||
});
|
||||
(session.consumables ?? []).forEach((c) => {
|
||||
costItems.push({
|
||||
label: c.item_name ?? 'کالای مصرفی',
|
||||
rials: c.line_total_rials ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
// خطوط بالا ناخالصاند (پیش از بیمه) ولی finalPrice سهمِ بیمار پس از بیمه است.
|
||||
// پس وقتی بیمه هست، جمعِ خطوط را ناخالص نشان بده و سهم بیمه را جدا کم کن.
|
||||
const grossTotal = session.gross_total_rials ?? finalPrice;
|
||||
const baseInsurance = session.base_insurance_rials ?? 0;
|
||||
const suppInsurance = session.supplementary_insurance_rials ?? 0;
|
||||
const hasInsurance = baseInsurance > 0 || suppInsurance > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,8 +93,28 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(it.rials)}</span>
|
||||
</div>
|
||||
))}
|
||||
{hasInsurance && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0 4px', borderTop: '1px dashed #E0E0E0' }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 700, color: '#2f2f2f' }}>جمع کل خدمات</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#2f2f2f' }}>{formatRial(grossTotal)}</span>
|
||||
</div>
|
||||
{baseInsurance > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0' }}>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#616161' }}>سهم بیمه پایه</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: '#2E7D32' }}>{formatRial(baseInsurance)}</span>
|
||||
</div>
|
||||
)}
|
||||
{suppInsurance > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0' }}>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#616161' }}>سهم بیمه تکمیلی</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: '#2E7D32' }}>{formatRial(suppInsurance)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0 4px', borderTop: '1px dashed #E0E0E0' }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 700, color: '#2f2f2f' }}>جمع کل</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 700, color: '#2f2f2f' }}>{hasInsurance ? 'سهم بیمار' : 'جمع کل'}</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#2f2f2f' }}>{formatRial(finalPrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,9 +124,10 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
|
||||
const baseInsurance = session.base_insurance_rials ?? 0;
|
||||
const suppInsurance = session.supplementary_insurance_rials ?? 0;
|
||||
const hasInsurance = baseInsurance > 0 || suppInsurance > 0;
|
||||
const payable = session.remaining_rials !== undefined
|
||||
? session.remaining_rials + (session.paid_total_rials ?? 0)
|
||||
: Math.max(0, finalPrice - discountRials);
|
||||
// همان فرمول سرور (PatientSession::getPayableRials). بازسازی از روی
|
||||
// `remaining + paid` غلط بود: سرور remaining را در صفر کلمپ میکند، پس در
|
||||
// بیشپرداخت مبلغ نهایی تا اندازهی پرداختی بالا میرفت و اضافهپرداخت پنهان میشد.
|
||||
const payable = Math.max(0, finalPrice - discountRials);
|
||||
const debt = session.remaining_rials ?? session.patient_debt_rials ?? 0;
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,8 @@ describe('PersianDateInput', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(<PersianDateInput value="2024-03-25" onChange={onChange} />);
|
||||
fireEvent.click((container.firstChild as HTMLElement).firstChild as Element);
|
||||
const target = Array.from(container.querySelectorAll('button')).find(
|
||||
// تقویم با createPortal به document.body میرود، پس بیرون از container است
|
||||
const target = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === '۱۵',
|
||||
);
|
||||
expect(target).toBeTruthy();
|
||||
|
||||
@@ -31,3 +31,25 @@ describe('SearchableSelect — تطبیق مقدار', () => {
|
||||
expect(screen.queryByText('تامین اجتماعی')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchableSelect — نام دسترسپذیر', () => {
|
||||
it('در نبود ariaLabel، از placeholder بهعنوان نام استفاده میکند', () => {
|
||||
render(<SearchableSelect options={insuranceOpts} onChange={() => {}} placeholder="نوع بیمه" />);
|
||||
expect(screen.getByRole('combobox', { name: 'نوع بیمه' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ariaLabel بر placeholder اولویت دارد', () => {
|
||||
render(<SearchableSelect options={insuranceOpts} onChange={() => {}} placeholder="انتخاب کنید..." ariaLabel="بیمه پایه" />);
|
||||
expect(screen.getByRole('combobox', { name: 'بیمه پایه' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ariaLabelledBy به label قابلمشاهده وصل میشود', () => {
|
||||
render(
|
||||
<>
|
||||
<span id="lbl-city">شهر</span>
|
||||
<SearchableSelect options={[{ value: 9, label: 'یزد' }]} onChange={() => {}} ariaLabelledBy="lbl-city" />
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: 'شهر' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,14 @@ interface Props {
|
||||
noOptionsMessage?: string;
|
||||
inputId?: string;
|
||||
height?: number;
|
||||
/**
|
||||
* نام دسترسپذیر فیلد. react-select ورودی داخلی خودش را بدون label رندر میکند و
|
||||
* placeholder را هم بهصورت div میگذارد نه attribute، پس بدون این، فیلد برای
|
||||
* screen reader بینام میماند. پیشفرض روی placeholder میافتد.
|
||||
*/
|
||||
ariaLabel?: string;
|
||||
/** اگر label قابلمشاهدهای وجود دارد، id آن را بده (بر ariaLabel اولویت دارد). */
|
||||
ariaLabelledBy?: string;
|
||||
}
|
||||
|
||||
export default function SearchableSelect({
|
||||
@@ -31,6 +39,8 @@ export default function SearchableSelect({
|
||||
noOptionsMessage = 'موردی یافت نشد',
|
||||
inputId,
|
||||
height = 42,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
}: Props) {
|
||||
const darkMode = useUiStore((s) => s.darkMode);
|
||||
|
||||
@@ -118,6 +128,8 @@ export default function SearchableSelect({
|
||||
noOptionsMessage={() => noOptionsMessage}
|
||||
loadingMessage={() => 'در حال بارگذاری...'}
|
||||
inputId={inputId}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? placeholder)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user