- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments. - Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements. - Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`. - Implemented `SecretaryEarning` entity and repository for managing secretary earnings. - Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments. - Added `UserIbanResolver` service to handle user IBAN retrieval and management. - Created `HasIbansTrait` for entities to manage IBANs in a JSON format. - Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
220 lines
9.2 KiB
TypeScript
220 lines
9.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { TrashIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import { formatDate, formatRial, tomanToRial } from '../lib/utils';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import StatCard from '../components/ui/StatCard';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import PriceInput from '../components/ui/PriceInput';
|
|
import DataTable, { Column } from '../components/ui/DataTable';
|
|
|
|
interface IbanItem { id: string; iban: string; bank_name: string | null; owner_name: string | null; verified: boolean }
|
|
interface SecretaryMe { bank_account: IbanItem[] | null }
|
|
interface SettlementRow {
|
|
uuid: string;
|
|
amount_rials: number;
|
|
status: string;
|
|
created_at: number;
|
|
bank_account?: { iban?: string } | null;
|
|
}
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
pending: 'در انتظار بررسی',
|
|
approved: 'تأییدشده',
|
|
rejected: 'رد شده',
|
|
paid: 'پرداختشده',
|
|
};
|
|
|
|
/** unwrap پاسخهای احتمالاً تودرتوی `success(['data' => …])`. */
|
|
const unwrap = <T,>(res: ApiResponse<T> | undefined): T | undefined =>
|
|
((res?.data as any)?.data ?? res?.data) as T | undefined;
|
|
|
|
/** تسویه حساب منشی: موجودی، مدیریت شبا و درخواست برداشت — الگوی پنل نماینده. */
|
|
export default function SecretarySettlementPage() {
|
|
const qc = useQueryClient();
|
|
const [amountToman, setAmountToman] = useState(0);
|
|
const [ibanId, setIbanId] = useState('');
|
|
const [newIban, setNewIban] = useState('');
|
|
const [bankName, setBankName] = useState('');
|
|
|
|
const balanceQ = useQuery<ApiResponse<{ balance_rials: number }>>({
|
|
queryKey: ['wallet-balance'],
|
|
queryFn: () => api.get('/api/v1/wallet/balance'),
|
|
});
|
|
const balance = unwrap(balanceQ.data)?.balance_rials ?? 0;
|
|
|
|
const meQ = useQuery<ApiResponse<SecretaryMe>>({
|
|
queryKey: ['secretary-me'],
|
|
queryFn: () => api.get('/api/v1/secretary/me'),
|
|
});
|
|
const ibans = unwrap(meQ.data)?.bank_account ?? [];
|
|
const verifiedIbans = ibans.filter((b) => b.verified);
|
|
|
|
const listQ = useQuery<ApiResponse<SettlementRow[]>>({
|
|
queryKey: ['secretary-settlements'],
|
|
queryFn: () => api.get('/api/v1/settlement?page=1&limit=15'),
|
|
});
|
|
const settlements = unwrap(listQ.data) ?? [];
|
|
|
|
const invalidateWallet = () => {
|
|
qc.invalidateQueries({ queryKey: ['wallet-balance'] });
|
|
qc.invalidateQueries({ queryKey: ['secretary-settlements'] });
|
|
};
|
|
|
|
const addIbanMut = useMutation({
|
|
mutationFn: () => api.post('/api/v1/secretary/iban', {
|
|
iban: newIban.trim().toUpperCase(),
|
|
...(bankName.trim() ? { bank_name: bankName.trim() } : {}),
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('شماره شبا ثبت شد؛ پس از تأیید ادمین قابل استفاده است');
|
|
setNewIban('');
|
|
setBankName('');
|
|
qc.invalidateQueries({ queryKey: ['secretary-me'] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const removeIbanMut = useMutation({
|
|
mutationFn: (id: string) => api.delete(`/api/v1/secretary/iban/${id}`),
|
|
onSuccess: () => {
|
|
toast.success('شماره شبا حذف شد');
|
|
qc.invalidateQueries({ queryKey: ['secretary-me'] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const requestMut = useMutation({
|
|
mutationFn: () => api.post('/api/v1/settlement', {
|
|
amount_rials: tomanToRial(amountToman),
|
|
iban_id: ibanId,
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('درخواست تسویه ثبت شد');
|
|
setAmountToman(0);
|
|
invalidateWallet();
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const columns: Column<SettlementRow>[] = [
|
|
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
|
|
{ key: 'amount_rials', header: 'مبلغ', render: (r) => formatRial(r.amount_rials) },
|
|
{ key: 'bank_account', header: 'شبا', render: (r) => <span dir="ltr">{r.bank_account?.iban ?? '—'}</span> },
|
|
{ key: 'status', header: 'وضعیت', render: (r) => STATUS_LABEL[r.status] ?? r.status },
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader title="تسویه حساب" description="برداشت سهم نوبتهای آنلاین به شماره شبای شما" />
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
|
|
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
|
|
</div>
|
|
|
|
<div className="card" style={{ padding: 20, marginBottom: 'var(--gap)' }}>
|
|
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px' }}>شماره شبا</h2>
|
|
|
|
{ibans.length === 0 ? (
|
|
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '0 0 12px' }}>هنوز شبایی ثبت نکردهاید.</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
|
{ibans.map((b) => (
|
|
<div key={b.id} style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
|
|
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
|
}}>
|
|
<span dir="ltr" style={{ flex: 1, fontSize: 13 }}>{b.iban}</span>
|
|
{b.bank_name && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>{b.bank_name}</span>}
|
|
<span style={{ fontSize: 11.5, fontWeight: 700, color: b.verified ? 'var(--success)' : 'var(--warning)' }}>
|
|
{b.verified ? 'تأییدشده' : 'در انتظار تأیید'}
|
|
</span>
|
|
<button
|
|
className="mini-btn danger"
|
|
title="حذف"
|
|
disabled={removeIbanMut.isPending}
|
|
onClick={() => removeIbanMut.mutate(b.id)}
|
|
>
|
|
<TrashIcon style={{ width: 14, height: 14 }} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{ibans.length < 2 && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, alignItems: 'end' }}>
|
|
<div className="form-row">
|
|
<label>شماره شبا</label>
|
|
<input
|
|
className="input"
|
|
dir="ltr"
|
|
aria-label="شماره شبا"
|
|
placeholder="IR..."
|
|
value={newIban}
|
|
onChange={(e) => setNewIban(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="form-row">
|
|
<label>نام بانک (اختیاری)</label>
|
|
<input className="input" aria-label="نام بانک" value={bankName} onChange={(e) => setBankName(e.target.value)} />
|
|
</div>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={addIbanMut.isPending || newIban.trim() === ''}
|
|
onClick={() => addIbanMut.mutate()}
|
|
>
|
|
{addIbanMut.isPending ? 'در حال ثبت...' : 'افزودن شبا'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card" style={{ padding: 20, marginBottom: 'var(--gap)' }}>
|
|
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px' }}>درخواست تسویه</h2>
|
|
|
|
{verifiedIbans.length === 0 ? (
|
|
<p style={{ fontSize: 12.5, color: 'var(--text-2)', margin: 0, lineHeight: 1.9 }}>
|
|
برای ثبت درخواست تسویه، باید حداقل یک شماره شبای <b>تأییدشده</b> داشته باشید.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, alignItems: 'end' }}>
|
|
<div className="form-row">
|
|
<label>مبلغ (تومان)</label>
|
|
<PriceInput value={amountToman} onChange={setAmountToman} suffix="تومان" />
|
|
</div>
|
|
<div className="form-row">
|
|
<label>شماره شبا</label>
|
|
<SearchableSelect
|
|
options={verifiedIbans.map((b) => ({ value: b.id, label: `${b.iban}${b.bank_name ? ` — ${b.bank_name}` : ''}` }))}
|
|
value={ibanId || null}
|
|
onChange={(v) => setIbanId(v ? String(v) : '')}
|
|
placeholder="انتخاب شبا"
|
|
/>
|
|
</div>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={requestMut.isPending || amountToman <= 0 || ibanId === ''}
|
|
onClick={() => requestMut.mutate()}
|
|
>
|
|
{requestMut.isPending ? 'در حال ثبت...' : 'ثبت درخواست'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<DataTable<SettlementRow>
|
|
columns={columns}
|
|
data={settlements}
|
|
loading={listQ.isLoading}
|
|
emptyMessage="درخواست تسویهای ثبت نشده است"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|