feat: Add online share functionality for secretaries
- 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.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user