Files
clinicpro/assets/admin/components/WalletTransactionModal.tsx
T
hamedandClaude Opus 4.8 f028d6841a 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>
2026-07-16 11:56:39 +03:30

216 lines
9.6 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, useState } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon, WalletIcon } from '@heroicons/react/24/outline';
import SearchableSelect from './ui/SearchableSelect';
import PersianDateInput from './ui/PersianDateInput';
import { formatRial, formatNumber, tomanToRial } from '../lib/utils';
export type WalletMode = 'charge' | 'withdraw';
interface Props {
open: boolean;
balanceRials: number;
submitting?: boolean;
onClose: () => void;
onSubmit: (payload: { mode: WalletMode; amount_rials: number; description?: string }) => 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: 'حساب اصلی' }];
/**
* مودال شارژ/برداشت کیف پول بیمار — پورت‌شده از tauri AddTransactionModal.
* یک مودال با toggle «شارژ کیف پول / برداشت از کیف پول»، مبالغ سریع، و فیلدهای فرم.
* مبلغ به تومان وارد و هنگام ثبت به ریال (واحد API) تبدیل می‌شود.
* روش پرداخت/حساب/تاریخ/ساعت فقط UI هستند (بدون ذخیره)، مطابق مبدأ.
*/
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 [description, setDescription] = useState('');
// با هر بار باز شدن، فرم ریست شود.
useEffect(() => {
if (open) {
setMode('charge'); setAmountToman(0); setMethod(''); setAccount('');
setDate(''); setTime(''); 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 amountLabel = isWithdraw ? 'مبلغ برداشت:' : 'مبلغ شارژ:';
const submit = () => {
if (!canSubmit) return;
onSubmit({
mode,
amount_rials: amountRials,
...(description.trim() ? { description: description.trim() } : {}),
});
};
const tab = (key: WalletMode, label: string) => {
const on = mode === key;
return (
<button
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',
}}
>
{label}
</button>
);
};
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 620 }} 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={{
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',
}}>
<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>
</div>
{/* toggle شارژ/برداشت */}
<div style={{ position: 'relative', display: 'flex', background: 'var(--primary-soft)', borderRadius: 12, padding: 4, marginBottom: 20 }}>
<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,
}} />
{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' }}
/>
</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 }}>
<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="توضیحات"
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,
);
}