feat: unify wallet with real payment methods, full transaction transparency, pay-session-from-wallet

Address wallet feedback: use the clinic's real payment infrastructure,
redesign the tab to match the admin panel, and make every wallet movement
fully auditable.

Backend:
- WalletTransaction: add createdBy (acting user) + createdByName, payment_method,
  reference, status; toArray exposes them (migration Version20260716083939).
- WalletService (Settlement): balance/charge/withdraw + settleSessionFromWallet,
  records actor/method/reason; insufficient balance throws ERR_WALLET_INSUFFICIENT.
- PatientController: charge/withdraw delegate to WalletService and accept
  payment_method/reference; PATCH /session/{uuid} with payment_method=wallet
  debits the patient's final share from the wallet (reference=session:{uuid}).
- docs/api/patient.md updated.

Frontend:
- Wallet modal redesigned to panel style (no gradient); payment method now uses
  the clinic's real bank accounts + POS devices (usePaymentMethods) plus cash.
- Wallet tab: panel balance card + DataTable ledger with columns مبلغ/نوع/روش/
  دلیل/ثبت‌کننده/تاریخ/ساعت/وضعیت + همه/واریزی/برداشت filters.
- Session card «تکمیل پرداخت» opens a payment-method chooser incl. کیف پول.

Tests: backend transparency + session-from-wallet (success/insufficient/cash);
frontend modal (real methods, toman→rials) + wallet tab + settle chooser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 12:22:37 +03:30
co-authored by Claude Opus 4.8
parent 22474653b5
commit ef97b2b249
12 changed files with 598 additions and 187 deletions
@@ -1,6 +1,12 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn().mockResolvedValue({ success: true, data: [] }), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import WalletTransactionModal from './WalletTransactionModal';
// موجودی نمونه: ۳۰۰٬۰۰۰ ریال = ۳۰٬۰۰۰ تومان
@@ -23,11 +29,11 @@ describe('WalletTransactionModal (شارژ/برداشت کیف پول)', () => {
expect(screen.getAllByRole('button', { name: /تومان/ }).length).toBe(4);
});
it('submits a charge with the amount converted from toman to rials', () => {
it('submits a charge with the amount converted from toman to rials and the default cash method', () => {
const onSubmit = open();
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
expect(onSubmit).toHaveBeenCalledWith({ mode: 'charge', amount_rials: 1_000_000 });
expect(onSubmit).toHaveBeenCalledWith({ mode: 'charge', amount_rials: 1_000_000, payment_method: 'cash' });
});
it('submits a withdraw (debit) when the برداشت tab is active and amount is within balance', () => {
@@ -35,7 +41,7 @@ describe('WalletTransactionModal (شارژ/برداشت کیف پول)', () => {
fireEvent.click(screen.getByRole('button', { name: 'برداشت از کیف پول' }));
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '20000' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
expect(onSubmit).toHaveBeenCalledWith({ mode: 'withdraw', amount_rials: 200_000 });
expect(onSubmit).toHaveBeenCalledWith({ mode: 'withdraw', amount_rials: 200_000, payment_method: 'cash' });
});
it('blocks a withdraw above the balance and shows the insufficient-funds hint', () => {
@@ -1,53 +1,62 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon, WalletIcon } from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/outline';
import SearchableSelect from './ui/SearchableSelect';
import PersianDateInput from './ui/PersianDateInput';
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
import { formatRial, formatNumber, tomanToRial } from '../lib/utils';
export type WalletMode = 'charge' | 'withdraw';
export interface WalletModalSubmit {
mode: WalletMode;
amount_rials: number;
description?: string;
payment_method?: string;
reference?: string;
}
interface Props {
open: boolean;
balanceRials: number;
submitting?: boolean;
onClose: () => void;
onSubmit: (payload: { mode: WalletMode; amount_rials: number; description?: string }) => void;
onSubmit: (payload: WalletModalSubmit) => void;
}
// مبالغ پیشنهادی سریع، به تومان (معادل tauri quickAmounts).
// مبالغ پیشنهادی سریع، به تومان.
const QUICK_TOMANS = [100_000, 200_000, 300_000, 400_000];
// روش پرداخت / حساب مقصد صرفاً UI هستند (در tauri هم mock بودند و بک‌اندی ندارند).
const PAYMENT_METHOD_OPTS = [
{ value: 'card', label: 'کارت به کارت' },
{ value: 'cash', label: 'نقدی' },
{ value: 'pos', label: 'دستگاه پوز' },
{ value: 'gateway', label: 'درگاه اینترنتی' },
];
const ACCOUNT_OPTS = [{ value: 'main', label: 'حساب اصلی' }];
interface MethodOption { value: string; label: string; method: string; reference: string | null }
/**
* مودال شارژ/برداشت کیف پول بیمار — پورت‌شده از tauri AddTransactionModal.
* یک مودال با toggle «شارژ کیف پول / برداشت از کیف پول»، مبالغ سریع، و فیلدهای فرم.
* مبلغ به تومان وارد و هنگام ثبت به ریال (واحد API) تبدیل می‌شود.
* روش پرداخت/حساب/تاریخ/ساعت فقط UI هستند (بدون ذخیره)، مطابق مبدأ.
* مودال شارژ/برداشت کیف پول بیمار — با طراحیِ پنل و روش‌های پرداختِ واقعیِ کلینیک
* (حساب بانکی/کارت‌خوان از /my/payment-methods + نقدی). مبلغ به تومان وارد و هنگام
* ثبت به ریال (واحد API) تبدیل می‌شود.
*/
export default function WalletTransactionModal({ open, balanceRials, submitting, onClose, onSubmit }: Props) {
const [mode, setMode] = useState<WalletMode>('charge');
const [amountToman, setAmountToman] = useState(0);
const [method, setMethod] = useState<string>('');
const [account, setAccount] = useState<string>('');
const [date, setDate] = useState('');
const [time, setTime] = useState('');
const [methodValue, setMethodValue] = useState<string>('cash');
const [description, setDescription] = useState('');
// با هر بار باز شدن، فرم ریست شود.
useEffect(() => {
if (open) {
setMode('charge'); setAmountToman(0); setMethod(''); setAccount('');
setDate(''); setTime(''); setDescription('');
const banksQ = useBankAccounts();
const posQ = usePosDevices();
const methodOptions = useMemo<MethodOption[]>(() => {
const opts: MethodOption[] = [{ value: 'cash', label: 'نقدی', method: 'cash', reference: null }];
for (const b of banksQ.data?.data ?? []) {
if (!b.is_active) continue;
opts.push({ value: `bank:${b.uuid}`, label: `کارت به کارت — ${b.bank_name}`, method: 'card', reference: `${b.bank_name}${b.card_number ? ` (${b.card_number})` : ''}` });
}
for (const p of posQ.data?.data ?? []) {
if (!p.is_active) continue;
opts.push({ value: `pos:${p.uuid}`, label: `کارت‌خوان — ${p.bank_name}`, method: 'pos', reference: `${p.bank_name} (${p.terminal_number})` });
}
return opts;
}, [banksQ.data, posQ.data]);
useEffect(() => {
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
}, [open]);
useEffect(() => {
@@ -63,14 +72,16 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
const isWithdraw = mode === 'withdraw';
const overBalance = isWithdraw && amountRials > balanceRials;
const canSubmit = amountToman > 0 && !overBalance && !submitting;
const amountLabel = isWithdraw ? 'مبلغ برداشت:' : 'مبلغ شارژ:';
const submit = () => {
if (!canSubmit) return;
const opt = methodOptions.find((o) => o.value === methodValue);
onSubmit({
mode,
amount_rials: amountRials,
...(description.trim() ? { description: description.trim() } : {}),
...(opt ? { payment_method: opt.method } : {}),
...(opt?.reference ? { reference: opt.reference } : {}),
});
};
@@ -81,9 +92,9 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
type="button"
onClick={() => setMode(key)}
style={{
flex: 1, borderRadius: 10, padding: '9px 0', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 600, zIndex: 2, background: 'transparent',
color: on ? '#fff' : 'var(--text)', transition: 'color .3s',
flex: 1, borderRadius: 'var(--r-sm)', padding: '9px 0', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13.5, fontWeight: 600, zIndex: 2, background: 'transparent',
color: on ? 'var(--on-primary)' : 'var(--text-2)', transition: 'color .25s var(--ease)',
}}
>
{label}
@@ -93,7 +104,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 620 }} onClick={(e) => e.stopPropagation()}>
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
<button type="button" className="mini-btn" onClick={onClose}>
@@ -102,100 +113,79 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
</div>
<div className="modal-body" dir="rtl">
{/* بنر موجودی (معادل هدر گرادیانی مبدأ) */}
{/* موجودی — کارت ساده مطابق پنل (بدون گرادیان) */}
<div style={{
background: 'linear-gradient(135deg, var(--primary), var(--primary-700))',
borderRadius: 'var(--r-lg)', padding: '18px 22px', color: '#fff', marginBottom: 20,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--primary-soft)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: '12px 16px', marginBottom: 18,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<WalletIcon style={{ width: 22, opacity: 0.9 }} />
<span style={{ fontSize: 14, fontWeight: 600 }}>موجودی کیف پول</span>
</div>
<div style={{ fontSize: 20, fontWeight: 800, direction: 'ltr' }}>{formatRial(balanceRials)}</div>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>موجودی کیف پول</span>
<span style={{ fontSize: 18, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</span>
</div>
{/* toggle شارژ/برداشت */}
<div style={{ position: 'relative', display: 'flex', background: 'var(--primary-soft)', borderRadius: 12, padding: 4, marginBottom: 20 }}>
<div style={{ position: 'relative', display: 'flex', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: 4, marginBottom: 18 }}>
<div style={{
position: 'absolute', top: 4, bottom: 4, width: 'calc(50% - 4px)',
right: isWithdraw ? 'calc(50% + 0px)' : 4, left: isWithdraw ? 4 : 'calc(50% + 0px)',
background: 'var(--primary)', borderRadius: 10, transition: 'all .3s var(--ease)', zIndex: 1,
right: isWithdraw ? 'calc(50%)' : 4, left: isWithdraw ? 4 : 'calc(50%)',
background: 'var(--primary)', borderRadius: 'var(--r-sm)', transition: 'all .25s var(--ease)', zIndex: 1,
}} />
{tab('charge', 'شارژ کیف پول')}
{tab('withdraw', 'برداشت از کیف پول')}
</div>
{/* مبلغ + مبالغ سریع */}
<div style={{ marginBottom: 18 }}>
<label className="field-label">{amountLabel}</label>
<div style={{ display: 'flex', gap: 8, margin: '8px 0 12px', flexWrap: 'wrap' }}>
{QUICK_TOMANS.map((t) => (
<button
key={t}
type="button"
onClick={() => setAmountToman(t)}
style={{
flex: '1 1 0', minWidth: 90, padding: '8px 6px', borderRadius: 10, cursor: 'pointer',
border: `1px solid ${amountToman === t ? 'var(--primary)' : 'var(--border)'}`,
background: amountToman === t ? 'var(--primary-soft)' : 'var(--surface)',
color: amountToman === t ? 'var(--primary)' : 'var(--text-2)',
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
}}
>
{formatNumber(t)} تومان
</button>
))}
</div>
<div className="field">
<input
inputMode="numeric"
value={amountToman > 0 ? formatNumber(amountToman) : ''}
onChange={(e) => {
const raw = e.target.value.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)).replace(/[^\d]/g, '');
setAmountToman(raw ? parseInt(raw, 10) : 0);
}}
placeholder="مبلغ دلخواه (تومان)"
style={{ textAlign: 'center', direction: 'ltr' }}
<label className="field-label">{isWithdraw ? 'مبلغ برداشت (تومان)' : 'مبلغ شارژ (تومان)'}</label>
<div style={{ display: 'flex', gap: 8, margin: '8px 0 10px', flexWrap: 'wrap' }}>
{QUICK_TOMANS.map((t) => {
const on = amountToman === t;
return (
<button key={t} type="button" onClick={() => setAmountToman(t)} style={{
flex: '1 1 0', minWidth: 92, padding: '8px 6px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
border: `1px solid ${on ? 'var(--primary)' : 'var(--border)'}`,
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary)' : 'var(--text-2)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
}}>{formatNumber(t)} تومان</button>
);
})}
</div>
<div className="field">
<input
inputMode="numeric"
value={amountToman > 0 ? formatNumber(amountToman) : ''}
onChange={(e) => {
const raw = e.target.value.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)).replace(/[^\d]/g, '');
setAmountToman(raw ? parseInt(raw, 10) : 0);
}}
placeholder="مبلغ دلخواه (تومان)"
style={{ textAlign: 'center', direction: 'ltr' }}
/>
</div>
{overBalance && <div style={{ color: 'var(--danger)', fontSize: 12, marginTop: 6 }}>موجودی کیف پول کافی نیست</div>}
{/* روش پرداخت واقعی */}
<div style={{ marginTop: 16 }}>
<label className="field-label">روش پرداخت</label>
<div style={{ marginTop: 8 }}>
<SearchableSelect
options={methodOptions.map((o) => ({ value: o.value, label: o.label }))}
value={methodValue}
onChange={(v) => setMethodValue(String(v ?? 'cash'))}
placeholder="روش پرداخت"
height={46}
/>
</div>
{overBalance && (
<div style={{ color: 'var(--danger)', fontSize: 12, marginTop: 6 }}>موجودی کیف پول کافی نیست</div>
)}
</div>
{/* روش پرداخت + حساب مقصد (UI-only) */}
<div style={{ marginBottom: 18 }}>
<label className="field-label">انتخاب روشهای پرداخت:</label>
<div style={{ marginTop: 8, marginBottom: 10 }}>
<SearchableSelect options={PAYMENT_METHOD_OPTS} value={method} onChange={(v) => setMethod(String(v ?? ''))} placeholder="کارت به کارت" height={46} />
</div>
<SearchableSelect options={ACCOUNT_OPTS} value={account} onChange={(v) => setAccount(String(v ?? ''))} placeholder="حساب مقصد" height={46} />
</div>
{/* تاریخ + ساعت (UI-only) */}
<div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
<div style={{ flex: 1 }}>
<label className="field-label">تاریخ</label>
<div style={{ marginTop: 8 }}><PersianDateInput value={date} onChange={setDate} /></div>
</div>
<div style={{ flex: 1 }}>
<label className="field-label">ساعت</label>
<div className="field" style={{ marginTop: 8 }}>
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} dir="ltr" />
</div>
</div>
</div>
{/* توضیحات */}
<div style={{ marginBottom: 6 }}>
<div style={{ marginTop: 16 }}>
<label className="field-label">توضیحات</label>
<div className="field" style={{ height: 'auto', marginTop: 8 }}>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="توضیحات"
rows={2}
placeholder="دلیل تراکنش (اختیاری)"
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }}
/>
</div>
+6
View File
@@ -8,6 +8,10 @@ export interface WalletTxn {
type: 'credit' | 'debit' | string;
description?: string | null;
balance_after: number;
created_by_name?: string | null;
payment_method?: string | null;
reference?: string | null;
status?: string;
created_at: number;
}
@@ -19,6 +23,8 @@ interface WalletData {
export interface WalletTxnInput {
amount_rials: number;
description?: string;
payment_method?: string;
reference?: string;
}
/**
+15 -3
View File
@@ -168,8 +168,8 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
expect(screen.getByRole('button', { name: 'همه' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'واریزی' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'برداشت' })).toBeInTheDocument();
// تراکنش credit اولیه دیده می‌شود
expect(screen.getByText('شارژ')).toBeInTheDocument();
// تراکنش credit اولیه دیده می‌شود (سطر جدول async لود می‌شود)
expect(await screen.findByText('شارژ')).toBeInTheDocument();
});
it('filters out the credit transaction when the برداشت filter is selected', async () => {
@@ -193,7 +193,19 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
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 }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', { amount_rials: 500000, payment_method: 'cash' }));
});
it('settles a session from the wallet via the payment-method chooser', async () => {
const patch = api.patch as ReturnType<typeof vi.fn>;
patch.mockResolvedValue({ success: true, data: {} });
renderDetail();
await loaded();
// تب سرویس‌ها پیش‌فرض است؛ کارت پرداخت‌نشده → «تکمیل پرداخت»
fireEvent.click(await screen.findByText('تکمیل پرداخت'));
expect(await screen.findByText('روش پرداخت مراجعه')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'کیف پول بیمار' }));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { payment_method: 'wallet' }));
});
it('renders the messages tab with a send box', async () => {
+92 -45
View File
@@ -13,7 +13,10 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types';
import { useAuthStore } from '../stores/authStore';
import { formatDate, formatRial } from '../lib/utils';
import { formatDate, formatRial, formatTime, formatNumber } from '../lib/utils';
import DataTable, { type Column } from '../components/ui/DataTable';
import type { WalletTxn } from '../hooks/usePatientWallet';
import type { WalletModalSubmit } from '../components/WalletTransactionModal';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PersianDateInput from '../components/ui/PersianDateInput';
@@ -76,6 +79,7 @@ export default function PatientDetailPage() {
const qc = useQueryClient();
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
const [settleTarget, setSettleTarget] = useState<string | null>(null);
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینه‌های فرم.
@@ -140,8 +144,15 @@ export default function PatientDetailPage() {
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
const settle = useMutation({
mutationFn: (sessionUuid: string) => api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: 'cash' }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] }); toast.success('پرداخت ثبت شد'); },
mutationFn: ({ sessionUuid, method }: { sessionUuid: string; method: string }) =>
api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: method }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] });
// پرداخت از کیف پول موجودی را کم می‌کند → دفتر کیف پول را هم تازه کن.
qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] });
toast.success('پرداخت ثبت شد');
setSettleTarget(null);
},
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت پرداخت'),
});
@@ -219,7 +230,7 @@ export default function PatientDetailPage() {
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
{sessions.map((s) => (
<SessionServiceCard key={s.uuid} session={s} settling={settle.isPending} onSettle={(u) => settle.mutate(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
<SessionServiceCard key={s.uuid} session={s} settling={settle.isPending} onSettle={(u) => setSettleTarget(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
))}
</div>
)}
@@ -243,6 +254,28 @@ export default function PatientDetailPage() {
)}
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
{/* انتخاب روش پرداختِ مراجعه (نقدی / کارت / کیف پول) */}
<Modal open={settleTarget !== null} title="روش پرداخت مراجعه" onClose={() => setSettleTarget(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>روش تسویهٔ این مراجعه را انتخاب کنید:</p>
{[
{ method: 'cash', label: 'نقدی' },
{ method: 'card', label: 'کارت به کارت' },
{ method: 'wallet', label: 'کیف پول بیمار' },
].map((m) => (
<button
key={m.method}
className="btn"
style={{ justifyContent: 'flex-start' }}
disabled={settle.isPending}
onClick={() => settleTarget && settle.mutate({ sessionUuid: settleTarget, method: m.method })}
>
{m.label}
</button>
))}
</div>
</Modal>
</div>
);
}
@@ -609,9 +642,17 @@ const WALLET_FILTERS: { key: WalletFilter; label: string }[] = [
{ key: 'debit', label: 'برداشت' },
];
const METHOD_LABEL: Record<string, string> = {
card: 'کارت به کارت', pos: 'کارت‌خوان', cash: 'نقدی', wallet: 'کیف پول', gateway: 'درگاه اینترنتی',
};
const STATUS_LABEL: Record<string, string> = { confirmed: 'تأیید شده' };
type WalletRow = WalletTxn & { row_no: number };
/**
* کیف پول — کارت موجودی + مودال شارژ/برداشت (toggle) + فیلتر همه/واریزی/برداشت
* روی دفتر تراکنش‌های اخیر. پورت‌شده از tauri WalletSection + AddTransactionModal.
* کیف پول — کارت موجودی + مودال شارژ/برداشت (روش پرداخت واقعی) + فیلتر
* همه/واریزی/برداشت روی دفترِ کاملِ تراکنش‌ها (DataTable با ستون ثبت‌کننده،
* روش پرداخت، دلیل و وضعیت). طراحی مطابق پنل.
*/
function WalletTab({ uuid }: { uuid: string }) {
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
@@ -620,36 +661,56 @@ function WalletTab({ uuid }: { uuid: string }) {
const submitting = charge.isPending || withdraw.isPending;
const handleSubmit = ({ mode, amount_rials, description }: { mode: 'charge' | 'withdraw'; amount_rials: number; description?: string }) => {
const handleSubmit = ({ mode, ...body }: WalletModalSubmit) => {
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 || 'خطا در ثبت تراکنش'),
mut.mutate(body, {
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 shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
const rows: WalletRow[] = shown.map((t, i) => ({ ...t, row_no: i + 1 }));
const columns: Column<WalletRow>[] = [
{ key: 'row_no', header: 'ردیف', className: 'w-[60px]', render: (r) => formatNumber(r.row_no) },
{
key: 'amount', header: 'مبلغ', render: (r) => (
<span style={{ fontWeight: 700, direction: 'ltr', color: r.type === 'credit' ? 'var(--success)' : 'var(--danger)' }}>
{r.type === 'credit' ? '+' : ''}{formatRial(r.amount_rials)}
</span>
),
},
{
key: 'type', header: 'نوع تراکنش', render: (r) => (
<span className={`badge ${r.type === 'credit' ? 'green' : 'red'}`}>{r.type === 'credit' ? 'واریز' : 'برداشت'}</span>
),
},
{ key: 'method', header: 'روش پرداخت', render: (r) => (r.payment_method ? METHOD_LABEL[r.payment_method] ?? r.payment_method : '—') },
{ key: 'reason', header: 'دلیل', render: (r) => r.description || '—' },
{ key: 'by', header: 'ثبت‌کننده', render: (r) => r.created_by_name || '—' },
{ key: 'date', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
{ key: 'time', header: 'ساعت', render: (r) => formatTime(r.created_at) },
{ key: 'status', header: 'وضعیت', render: (r) => <span className="badge gray">{STATUS_LABEL[r.status ?? ''] ?? 'تأیید شده'}</span> },
];
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(balanceRials)}</div>
<button className="btn sm" style={{ marginTop: 12, color: '#fff', border: 'none', background: '#5559ce' }}
onClick={() => setModalOpen(true)}>
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
{/* موجودی + دکمه تراکنش جدید */}
<div className="card card-pad" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
<div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div>
</div>
<button className="btn primary" onClick={() => setModalOpen(true)}>
<PlusIcon style={{ width: 16 }} /> شارژ کیف پول
</button>
</div>
{/* فیلتر تراکنش‌ها: همه / واریزی / برداشت (معادل 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) => {
@@ -673,26 +734,12 @@ function WalletTab({ uuid }: { uuid: string }) {
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 }}>
{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' }}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 600 }}>{t.description || (credit ? 'واریز' : 'برداشت')}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(t.created_at)}</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, direction: 'ltr', color: credit ? 'var(--success)' : 'var(--danger)' }}>
{credit ? '+' : ''}{formatRial(t.amount_rials)}
</div>
</div>
);
})}
</div>
)}
<DataTable<WalletRow>
columns={columns}
data={rows}
loading={isLoading}
emptyMessage="تراکنشی ثبت نشده است"
/>
</div>
);
}