feat: port wallet charge/withdraw modal from tauri to patient admin page
Add manual wallet withdrawal (debit) endpoint mirroring the offline app's
balance guard, and rebuild the patient کیف پول tab around a single
charge/withdraw toggle modal (quick amounts, تومان→ریال conversion,
transaction filters).
Backend:
- POST /api/v1/patient/{uuid}/wallet/withdraw — creates a debit
WalletTransaction; 422 ERR_WALLET_INSUFFICIENT when amount exceeds balance.
- ErrorCodes: ERR_WALLET_INSUFFICIENT ('موجودی کیف پول کافی نیست').
- docs/api/patient.md updated.
Frontend:
- usePatientWallet hook (balance + charge/withdraw mutations).
- WalletTransactionModal (toggle, quick amounts, UI-only payment fields).
- WalletTab: charge button, همه/واریزی/برداشت filters.
Tests: backend withdraw success/insufficient/non-positive/ownership;
frontend modal + wallet tab interactions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,12 +158,42 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
||||
expect(screen.getByText('هیچ پرداختی ثبت نشده است.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows wallet balance on the wallet tab', async () => {
|
||||
it('shows wallet balance, charge button and transaction filters on the wallet tab', async () => {
|
||||
renderDetail();
|
||||
await loaded();
|
||||
fireEvent.click(screen.getByText('کیف پول'));
|
||||
expect(await screen.findByText('موجودی کیف پول')).toBeInTheDocument();
|
||||
expect(await screen.findByText('شارژ')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /شارژ کیف پول/ })).toBeInTheDocument();
|
||||
// فیلترهای تراکنش
|
||||
expect(screen.getByRole('button', { name: 'همه' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'واریزی' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'برداشت' })).toBeInTheDocument();
|
||||
// تراکنش credit اولیه دیده میشود
|
||||
expect(screen.getByText('شارژ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters out the credit transaction when the برداشت filter is selected', async () => {
|
||||
renderDetail();
|
||||
await loaded();
|
||||
fireEvent.click(screen.getByText('کیف پول'));
|
||||
await screen.findByText('موجودی کیف پول');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'برداشت' }));
|
||||
expect(screen.getByText('تراکنشی ثبت نشده است')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the charge/withdraw modal and posts a charge', async () => {
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
post.mockResolvedValue({ success: true, data: {} });
|
||||
renderDetail();
|
||||
await loaded();
|
||||
fireEvent.click(screen.getByText('کیف پول'));
|
||||
await screen.findByText('موجودی کیف پول');
|
||||
fireEvent.click(screen.getByRole('button', { name: /شارژ کیف پول/ }));
|
||||
// مودال باز شد → تب برداشت هم دیده میشود
|
||||
expect(await screen.findByRole('button', { name: 'برداشت از کیف پول' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '50000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', { amount_rials: 500000 }));
|
||||
});
|
||||
|
||||
it('renders the messages tab with a send box', async () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { formatDate, formatRial } from '../lib/utils';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import PriceInput from '../components/ui/PriceInput';
|
||||
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
||||
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
||||
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
||||
@@ -29,6 +28,8 @@ import {
|
||||
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
|
||||
} from '../components/icons/FilesServiceIcons';
|
||||
import PatientRecordInfoForm from '../components/PatientRecordInfoForm';
|
||||
import WalletTransactionModal from '../components/WalletTransactionModal';
|
||||
import { usePatientWallet } from '../hooks/usePatientWallet';
|
||||
import {
|
||||
profileToFormValues, formValuesToPayload,
|
||||
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
|
||||
@@ -600,66 +601,83 @@ function CallCenterTab({ uuid }: { uuid: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface WalletTxn { uuid: string; amount_rials: number; type: string; description?: string | null; balance_after: number; created_at: number }
|
||||
type WalletFilter = 'all' | 'credit' | 'debit';
|
||||
|
||||
/** کیف پول — patient wallet balance card + manual top-up + recent-transaction ledger. */
|
||||
const WALLET_FILTERS: { key: WalletFilter; label: string }[] = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'credit', label: 'واریزی' },
|
||||
{ key: 'debit', label: 'برداشت' },
|
||||
];
|
||||
|
||||
/**
|
||||
* کیف پول — کارت موجودی + مودال شارژ/برداشت (toggle) + فیلتر همه/واریزی/برداشت
|
||||
* روی دفتر تراکنشهای اخیر. پورتشده از tauri WalletSection + AddTransactionModal.
|
||||
*/
|
||||
function WalletTab({ uuid }: { uuid: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [chargeOpen, setChargeOpen] = useState(false);
|
||||
const [amountRials, setAmountRials] = useState(0);
|
||||
const [description, setDescription] = useState('');
|
||||
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<WalletFilter>('all');
|
||||
|
||||
const { data, isLoading } = useQuery<ApiResponse<{ balance_rials: number; recent_transactions: WalletTxn[] }>>({
|
||||
queryKey: ['patient-wallet', uuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/wallet`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
const submitting = charge.isPending || withdraw.isPending;
|
||||
|
||||
const charge = useMutation({
|
||||
mutationFn: () => api.post(`/api/v1/patient/${uuid}/wallet/charge`, {
|
||||
amount_rials: amountRials,
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] });
|
||||
toast.success('کیف پول شارژ شد');
|
||||
setChargeOpen(false); setAmountRials(0); setDescription('');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.message || 'خطا در شارژ کیف پول'),
|
||||
});
|
||||
const handleSubmit = ({ mode, amount_rials, description }: { mode: 'charge' | 'withdraw'; amount_rials: number; description?: string }) => {
|
||||
const mut = mode === 'charge' ? charge : withdraw;
|
||||
mut.mutate(
|
||||
{ amount_rials, ...(description ? { description } : {}) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(mode === 'charge' ? 'کیف پول شارژ شد' : 'برداشت از کیف پول انجام شد');
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت تراکنش'),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
||||
const balance = data?.data?.balance_rials ?? 0;
|
||||
const txns = data?.data?.recent_transactions ?? [];
|
||||
|
||||
const shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20, marginBottom: 16, maxWidth: 320 }}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balance)}</div>
|
||||
<button className="btn sm" style={{ marginTop: 12, color: 'var(--accent)', border: '1px solid var(--accent)', background: 'var(--accent-bg)' }}
|
||||
onClick={() => setChargeOpen(true)}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balanceRials)}</div>
|
||||
<button className="btn sm" style={{ marginTop: 12, color: '#fff', border: 'none', background: '#5559ce' }}
|
||||
onClick={() => setModalOpen(true)}>
|
||||
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal open={chargeOpen} title="شارژ کیف پول" onClose={() => setChargeOpen(false)}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>مبلغ شارژ (تومان)</label>
|
||||
<div style={{ margin: '6px 0 12px' }}><PriceInput value={amountRials} onChange={setAmountRials} /></div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>توضیحات</label>
|
||||
<div className="field" style={{ margin: '6px 0 16px' }}>
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="مثلاً: بیعانه نوبت" />
|
||||
</div>
|
||||
<button className="btn primary" style={{ width: '100%' }} disabled={amountRials <= 0 || charge.isPending} onClick={() => charge.mutate()}>
|
||||
ثبت شارژ
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{txns.length === 0 ? (
|
||||
{/* فیلتر تراکنشها: همه / واریزی / برداشت (معادل tauri filters) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>تراکنشها:</span>
|
||||
{WALLET_FILTERS.map((f) => {
|
||||
const on = filter === f.key;
|
||||
return (
|
||||
<button key={f.key} onClick={() => setFilter(f.key)} style={{
|
||||
padding: '6px 14px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: on ? 700 : 500,
|
||||
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
color: on ? 'var(--primary)' : 'var(--text-2)',
|
||||
}}>{f.label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<WalletTransactionModal
|
||||
open={modalOpen}
|
||||
balanceRials={balanceRials}
|
||||
submitting={submitting}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{txns.map((t) => {
|
||||
{shown.map((t) => {
|
||||
const credit = t.type === 'credit';
|
||||
return (
|
||||
<div key={t.uuid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '12px 14px' }}>
|
||||
|
||||
Reference in New Issue
Block a user