Files
clinicpro/assets/admin/pages/SecretarySettlementPage.tsx
T
hamed 8d2b0d908a 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.
2026-07-25 18:34:18 +03:30

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>
);
}