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:
hamed
2026-07-15 11:39:32 +03:30
co-authored by Claude Opus 4.8
parent cac2e8b46a
commit b459d082a4
19 changed files with 1631 additions and 82 deletions
@@ -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: 'رفاه' },
];