feat: port payment management tab from tauri to admin dashboard
Add per-clinic payment methods (bank accounts + POS/card-reader devices)
under the "مدیریت پرداخت" settings tab at /admin/my-financial, ported from
clinic-pro-tauri's mock-only PaymentManagement tab into a real persisted
feature. These records are referenceable (by uuid) from patient invoices to
record which method a service payment was made with.
Backend (new src/PaymentMethod domain):
- BankAccount + Pos entities, repositories, PaymentMethodService (validation,
ownership scoping, create/update/toggle logic).
- Thin PaymentMethodController exposing /api/v1/my/payment-methods/{bank-accounts,pos}
(GET/POST/PUT + PATCH .../status), guarded to clinic/doctor/secretary/admin.
- Migration for bank_accounts + pos_devices tables.
- Functional tests (success + validation/404/403 + empty boundaries).
- docs/api/payment-method.md.
Frontend:
- Replace MyFinancialPage content with the payment-management UI (two tabs,
tables, add/edit modals, status toggle) using the admin design system.
- usePaymentMethods hook (TanStack Query) + presentational components.
- Update page test to cover tabs, data, empty state and the add modal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import { BANK_OPTIONS } from './banks';
|
||||
import {
|
||||
useCreateBankAccount,
|
||||
useUpdateBankAccount,
|
||||
type BankAccount,
|
||||
} from '../../hooks/usePaymentMethods';
|
||||
|
||||
/**
|
||||
* فرم افزودن/ویرایش حساب بانکی — پورت مبدأ ModalAddBankAccount.jsx.
|
||||
* اگر `account` داده شود حالت ویرایش، وگرنه افزودن.
|
||||
*/
|
||||
export default function BankAccountFormModal({
|
||||
open,
|
||||
account,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
account: BankAccount | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isEdit = !!account;
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [cardNumber, setCardNumber] = useState('');
|
||||
const [accountNumber, setAccountNumber] = useState('');
|
||||
const [shabaNumber, setShabaNumber] = useState('');
|
||||
|
||||
const createMut = useCreateBankAccount();
|
||||
const updateMut = useUpdateBankAccount();
|
||||
const pending = createMut.isPending || updateMut.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setBankName(account?.bank_name ?? '');
|
||||
setCardNumber(account?.card_number ?? '');
|
||||
setAccountNumber(account?.account_number ?? '');
|
||||
setShabaNumber(account?.shaba_number ?? '');
|
||||
}, [open, account]);
|
||||
|
||||
const submit = () => {
|
||||
if (!bankName.trim()) { toast.error('نام بانک الزامی است'); return; }
|
||||
if (!accountNumber.trim()) { toast.error('شماره حساب الزامی است'); return; }
|
||||
|
||||
const body = {
|
||||
bank_name: bankName.trim(),
|
||||
account_number: accountNumber.trim(),
|
||||
card_number: cardNumber.trim(),
|
||||
shaba_number: shabaNumber.trim(),
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
toast.success(isEdit ? 'حساب بانکی ویرایش شد' : 'حساب بانکی اضافه شد');
|
||||
onClose();
|
||||
};
|
||||
const onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در ذخیره');
|
||||
|
||||
if (isEdit && account) {
|
||||
updateMut.mutate({ uuid: account.uuid, body }, { onSuccess, onError });
|
||||
} else {
|
||||
createMut.mutate(body, { onSuccess, onError });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? 'ویرایش حساب بانکی' : 'افزودن حساب بانکی'}
|
||||
size="sm"
|
||||
footer={
|
||||
<button className="btn primary" style={{ width: '100%' }} disabled={pending} onClick={submit}>
|
||||
{pending ? '...' : isEdit ? 'ذخیره تغییرات' : 'اضافه کردن حساب بانکی'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">نام بانک</label>
|
||||
<SearchableSelect
|
||||
options={BANK_OPTIONS}
|
||||
value={bankName || null}
|
||||
onChange={(v) => setBankName(v != null ? String(v) : '')}
|
||||
placeholder="انتخاب بانک"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره کارت</label>
|
||||
<input className="input" value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} placeholder="شماره کارت" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره حساب</label>
|
||||
<input className="input" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="شماره حساب" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شبا</label>
|
||||
<input className="input" value={shabaNumber} onChange={(e) => setShabaNumber(e.target.value)} placeholder="شبا" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||
import DataTable, { type Column } from '../ui/DataTable';
|
||||
import StatusToggle from './StatusToggle';
|
||||
import type { BankAccount } from '../../hooks/usePaymentMethods';
|
||||
|
||||
/**
|
||||
* جدول حسابهای بانکی — پورت مبدأ BankAccountList.jsx.
|
||||
* ستون «شماره ترمینال» مبدأ به «شماره کارت» تغییر کرد (حساب بانکی ترمینال ندارد؛
|
||||
* فرم افزودن شماره کارت میگیرد).
|
||||
*/
|
||||
export default function BankAccountTable({
|
||||
data,
|
||||
loading,
|
||||
togglingUuid,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onAdd,
|
||||
}: {
|
||||
data: BankAccount[];
|
||||
loading?: boolean;
|
||||
togglingUuid: string | null;
|
||||
onToggle: (uuid: string) => void;
|
||||
onEdit: (account: BankAccount) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const columns: Column<BankAccount>[] = [
|
||||
{ key: 'bank_name', header: 'نام بانک' },
|
||||
{ key: 'card_number', header: 'شماره کارت', render: (r) => r.card_number || '—' },
|
||||
{ key: 'account_number', header: 'شماره حساب', render: (r) => r.account_number || '—' },
|
||||
{
|
||||
key: 'is_active',
|
||||
header: 'وضعیت',
|
||||
render: (r) => (
|
||||
<StatusToggle
|
||||
active={r.is_active}
|
||||
disabled={togglingUuid === r.uuid}
|
||||
onToggle={() => onToggle(r.uuid)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--text-2)', margin: '0 0 14px' }}>
|
||||
مدیریت و اضافه کردن حساب های بانکی کلینیک
|
||||
</p>
|
||||
<DataTable<BankAccount>
|
||||
columns={columns}
|
||||
data={data}
|
||||
loading={loading}
|
||||
emptyMessage="هنوز حساب بانکی ثبت نشده است"
|
||||
emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن حساب بانکی</button>}
|
||||
actions={(r) => (
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => onEdit(r)}
|
||||
>
|
||||
<PencilIcon style={{ width: 14 }} />
|
||||
ویرایش
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export type PaymentTab = 'bank' | 'pos';
|
||||
|
||||
/**
|
||||
* سربرگ صفحهٔ مدیریت پرداخت — پورت مبدأ PaymentManagement/Head.jsx.
|
||||
* تیتر + سوییچ تب (حساب بانکی / کارت خوان) + دکمهٔ افزودن.
|
||||
*/
|
||||
export default function PaymentMethodHead({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onAdd,
|
||||
}: {
|
||||
activeTab: PaymentTab;
|
||||
onTabChange: (tab: PaymentTab) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', margin: '0 0 16px' }}>
|
||||
مدیریت پرداختها
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="seg" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'bank'}
|
||||
className={activeTab === 'bank' ? 'on' : ''}
|
||||
onClick={() => onTabChange('bank')}
|
||||
>
|
||||
حساب بانکی
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'pos'}
|
||||
className={activeTab === 'pos' ? 'on' : ''}
|
||||
onClick={() => onTabChange('pos')}
|
||||
>
|
||||
کارت خوان
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn primary" onClick={onAdd}>
|
||||
<PlusIcon style={{ width: 16 }} />
|
||||
{activeTab === 'bank' ? 'افزودن حساب بانکی' : 'افزودن کارت خوان'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import { BANK_OPTIONS } from './banks';
|
||||
import {
|
||||
useCreatePos,
|
||||
useUpdatePos,
|
||||
type Pos,
|
||||
} from '../../hooks/usePaymentMethods';
|
||||
|
||||
/**
|
||||
* فرم افزودن/ویرایش کارتخوان — پورت مبدأ ModalAddPose.jsx.
|
||||
* اگر `pos` داده شود حالت ویرایش، وگرنه افزودن.
|
||||
*/
|
||||
export default function PosFormModal({
|
||||
open,
|
||||
pos,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
pos: Pos | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isEdit = !!pos;
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [terminalNumber, setTerminalNumber] = useState('');
|
||||
const [accountNumber, setAccountNumber] = useState('');
|
||||
|
||||
const createMut = useCreatePos();
|
||||
const updateMut = useUpdatePos();
|
||||
const pending = createMut.isPending || updateMut.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setBankName(pos?.bank_name ?? '');
|
||||
setTerminalNumber(pos?.terminal_number ?? '');
|
||||
setAccountNumber(pos?.account_number ?? '');
|
||||
}, [open, pos]);
|
||||
|
||||
const submit = () => {
|
||||
if (!bankName.trim()) { toast.error('نام بانک الزامی است'); return; }
|
||||
if (!terminalNumber.trim()) { toast.error('شماره ترمینال الزامی است'); return; }
|
||||
|
||||
const body = {
|
||||
bank_name: bankName.trim(),
|
||||
terminal_number: terminalNumber.trim(),
|
||||
account_number: accountNumber.trim(),
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
toast.success(isEdit ? 'کارت خوان ویرایش شد' : 'کارت خوان اضافه شد');
|
||||
onClose();
|
||||
};
|
||||
const onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در ذخیره');
|
||||
|
||||
if (isEdit && pos) {
|
||||
updateMut.mutate({ uuid: pos.uuid, body }, { onSuccess, onError });
|
||||
} else {
|
||||
createMut.mutate(body, { onSuccess, onError });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? 'ویرایش کارت خوان' : 'افزودن کارت خوان'}
|
||||
size="sm"
|
||||
footer={
|
||||
<button className="btn primary" style={{ width: '100%' }} disabled={pending} onClick={submit}>
|
||||
{pending ? '...' : isEdit ? 'ذخیره تغییرات' : 'اضافه کردن کارت خوان'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">نام بانک</label>
|
||||
<SearchableSelect
|
||||
options={BANK_OPTIONS}
|
||||
value={bankName || null}
|
||||
onChange={(v) => setBankName(v != null ? String(v) : '')}
|
||||
placeholder="انتخاب بانک"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره ترمینال</label>
|
||||
<input className="input" value={terminalNumber} onChange={(e) => setTerminalNumber(e.target.value)} placeholder="شماره ترمینال" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره حساب</label>
|
||||
<input className="input" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="شماره حساب" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||
import DataTable, { type Column } from '../ui/DataTable';
|
||||
import StatusToggle from './StatusToggle';
|
||||
import type { Pos } from '../../hooks/usePaymentMethods';
|
||||
|
||||
/**
|
||||
* جدول کارتخوانها — پورت مبدأ PoseList.jsx.
|
||||
* ستونها: نام بانک، شماره سریال، شماره ترمینال، وضعیت، عملیات.
|
||||
*/
|
||||
export default function PosTable({
|
||||
data,
|
||||
loading,
|
||||
togglingUuid,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onAdd,
|
||||
}: {
|
||||
data: Pos[];
|
||||
loading?: boolean;
|
||||
togglingUuid: string | null;
|
||||
onToggle: (uuid: string) => void;
|
||||
onEdit: (pos: Pos) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const columns: Column<Pos>[] = [
|
||||
{ key: 'bank_name', header: 'نام بانک' },
|
||||
{ key: 'serial_number', header: 'شماره سریال', render: (r) => r.serial_number || '—' },
|
||||
{ key: 'terminal_number', header: 'شماره ترمینال', render: (r) => r.terminal_number || '—' },
|
||||
{
|
||||
key: 'is_active',
|
||||
header: 'وضعیت',
|
||||
render: (r) => (
|
||||
<StatusToggle
|
||||
active={r.is_active}
|
||||
disabled={togglingUuid === r.uuid}
|
||||
onToggle={() => onToggle(r.uuid)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--text-2)', margin: '0 0 14px' }}>
|
||||
مدیریت و اضافه کردن دستگاه های کارت خوان موجود
|
||||
</p>
|
||||
<DataTable<Pos>
|
||||
columns={columns}
|
||||
data={data}
|
||||
loading={loading}
|
||||
emptyMessage="هنوز کارت خوانی ثبت نشده است"
|
||||
emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن کارت خوان</button>}
|
||||
actions={(r) => (
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => onEdit(r)}
|
||||
>
|
||||
<PencilIcon style={{ width: 14 }} />
|
||||
ویرایش
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* سوییچ وضعیت فعال/غیرفعال — معادل MUI Switch مبدأ با دیزاینسیستم مقصد.
|
||||
* برچسب فعال/غیرفعال کنار سوییچ نمایش داده میشود (مثل مبدأ).
|
||||
*/
|
||||
export default function StatusToggle({
|
||||
active,
|
||||
onToggle,
|
||||
disabled,
|
||||
}: {
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const label = active ? 'فعال' : 'غیرفعال';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label className="switch" title={label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={onToggle}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</label>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SelectOption } from '../ui/SearchableSelect';
|
||||
|
||||
/**
|
||||
* فهرست بانکهای قابل انتخاب — عیناً از مبدأ clinic-pro-tauri
|
||||
* (PaymentManagement/list/ModalAddBankAccount.jsx). value = label تا نام بانک
|
||||
* مستقیم ذخیره شود (بکاند نام را نگه میدارد، نه شناسه).
|
||||
*/
|
||||
export const BANK_OPTIONS: SelectOption[] = [
|
||||
{ value: 'ملی', label: 'ملی' },
|
||||
{ value: 'ملت', label: 'ملت' },
|
||||
{ value: 'تجارت', label: 'تجارت' },
|
||||
{ value: 'صادرات', label: 'صادرات' },
|
||||
{ value: 'سپه', label: 'سپه' },
|
||||
{ value: 'پاسارگاد', label: 'پاسارگاد' },
|
||||
{ value: 'رفاه', label: 'رفاه' },
|
||||
];
|
||||
Reference in New Issue
Block a user