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
+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>
);
}