Files
clinicpro/assets/admin/components/WalletTransactionModal.tsx
T
hamedandClaude Opus 4.8 ef97b2b249 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>
2026-07-16 12:22:37 +03:30

206 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
import SearchableSelect from './ui/SearchableSelect';
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: WalletModalSubmit) => void;
}
// مبالغ پیشنهادی سریع، به تومان.
const QUICK_TOMANS = [100_000, 200_000, 300_000, 400_000];
interface MethodOption { value: string; label: string; method: string; reference: string | null }
/**
* مودال شارژ/برداشت کیف پول بیمار — با طراحیِ پنل و روش‌های پرداختِ واقعیِ کلینیک
* (حساب بانکی/کارت‌خوان از /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 [methodValue, setMethodValue] = useState<string>('cash');
const [description, setDescription] = useState('');
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(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const amountRials = tomanToRial(amountToman);
const isWithdraw = mode === 'withdraw';
const overBalance = isWithdraw && amountRials > balanceRials;
const canSubmit = amountToman > 0 && !overBalance && !submitting;
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 } : {}),
});
};
const tab = (key: WalletMode, label: string) => {
const on = mode === key;
return (
<button
type="button"
onClick={() => setMode(key)}
style={{
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}
</button>
);
};
return createPortal(
<div className="overlay" onClick={onClose}>
<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}>
<XMarkIcon style={{ width: 18, height: 18 }} />
</button>
</div>
<div className="modal-body" dir="rtl">
{/* موجودی — کارت ساده مطابق پنل (بدون گرادیان) */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--primary-soft)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: '12px 16px', marginBottom: 18,
}}>
<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(--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%)' : 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>
{/* مبلغ + مبالغ سریع */}
<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>
</div>
{/* توضیحات */}
<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={2}
placeholder="دلیل تراکنش (اختیاری)"
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }}
/>
</div>
</div>
</div>
<div className="modal-foot">
<button type="button" className="btn" onClick={onClose}>انصراف</button>
<button type="button" className="btn primary" disabled={!canSubmit} onClick={submit}>
{submitting ? 'در حال ثبت...' : 'ثبت تراکنش'}
</button>
</div>
</div>
</div>,
document.body,
);
}