diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx new file mode 100644 index 00000000..35e11d4c --- /dev/null +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx @@ -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( + {}} />, + ); +} + +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(); + }); +}); diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx index 831c286e..be2e9158 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx @@ -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 = { + 'بدون پرداخت': { 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={ <> - + {amountToman > 0 && ( + + )} {overpaid && ( -

+

مبلغ پرداخت از جمع کل بیشتر است.

)} -
+ {/* خلاصه */} +
پرداخت‌شده - {formatRial(Math.min(amountRials, total))} + {formatRial(Math.min(amountRials, total))}
-
+
باقی‌مانده - {formatRial(remaining)} + 0 ? 'var(--danger)' : 'var(--success)' }}> + {formatRial(remaining)} +
-
+
وضعیت پرداخت - {paymentState} + + {paymentState} +