GET /billing/claims and GET /settlement now return only the first 50 rows by default (data.data unchanged, data.meta added). ClaimsPage and RepresentationSettlementPage read the full array with no pager, so rows beyond 50 were unreachable. Add page state + ?page/limit + the existing <Pagination> (reading data.meta.totalRecords). No change needed for: 422 on claim approve/pay (api.ts already surfaces the backend message via toast; the admin UI sends no amount so it's unreachable), the owner-only appointment-settings endpoints (admin user bypasses), and refresh rotation (authStore.refresh already persists the rotated refresh_token). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
8.3 KiB
TypeScript
176 lines
8.3 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import { formatRial, formatDate } from '../lib/utils';
|
|
import Pagination from '../components/ui/Pagination';
|
|
|
|
interface WalletBalance { balance_rials: number }
|
|
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
|
|
interface SettlementRow {
|
|
uuid: string;
|
|
amount_rials: number;
|
|
status: 'pending' | 'approved' | 'rejected' | 'paid';
|
|
admin_note: string | null;
|
|
created_at: number;
|
|
}
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
pending: 'در انتظار بررسی', approved: 'تأیید شده', rejected: 'رد شده', paid: 'پرداخت شده',
|
|
};
|
|
const STATUS_CLASS: Record<string, string> = {
|
|
pending: 'gray', approved: 'green', rejected: 'red', paid: 'green',
|
|
};
|
|
|
|
interface IbanItem { id: string; iban: string; bank_name: string | null; verified: boolean }
|
|
interface RepMe { bank_account: IbanItem[] | null }
|
|
|
|
export default function RepresentationSettlementPage() {
|
|
const qc = useQueryClient();
|
|
const [amount, setAmount] = useState('');
|
|
const [ibanId, setIbanId] = useState('');
|
|
const [page, setPage] = useState(1);
|
|
const limit = 20;
|
|
|
|
const balanceQ = useQuery({
|
|
queryKey: ['wallet-balance'],
|
|
queryFn: () => api.get<ApiResponse<WalletBalance>>('/api/v1/wallet/balance'),
|
|
staleTime: 30_000,
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const balance: number = ((balanceQ.data?.data as any)?.data ?? balanceQ.data?.data)?.balance_rials ?? 0;
|
|
|
|
const meQ = useQuery({
|
|
queryKey: ['representation-me'],
|
|
queryFn: () => api.get<ApiResponse<RepMe>>('/api/v1/representation/me'),
|
|
staleTime: 30_000,
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const ibans: IbanItem[] = (((meQ.data?.data as any)?.data ?? meQ.data?.data)?.bank_account ?? []).filter((b: IbanItem) => b.verified);
|
|
|
|
const summaryQ = useQuery({
|
|
queryKey: ['representation-summary'],
|
|
queryFn: () => api.get<ApiResponse<RepSummary>>('/api/v1/representation/dashboard/summary'),
|
|
staleTime: 60_000,
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const income = useMemo<any>(() => ((summaryQ.data?.data as any)?.data ?? summaryQ.data?.data)?.income, [summaryQ.data]);
|
|
|
|
const listQ = useQuery({
|
|
queryKey: ['settlements-mine', page],
|
|
queryFn: () => api.get<ApiResponse<SettlementRow[]>>(`/api/v1/settlement?page=${page}&limit=${limit}`),
|
|
staleTime: 30_000,
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const settlements: SettlementRow[] = (listQ.data?.data as any)?.data ?? listQ.data?.data ?? [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const settlementsTotal: number = (listQ.data?.data as any)?.meta?.totalRecords ?? settlements.length;
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (payload: { amount_rials: number; iban_id: string }) =>
|
|
api.post<ApiResponse<SettlementRow>>('/api/v1/settlement', payload),
|
|
onSuccess: () => {
|
|
toast.success('درخواست تسویه ثبت شد');
|
|
setAmount('');
|
|
qc.invalidateQueries({ queryKey: ['settlements-mine'] });
|
|
qc.invalidateQueries({ queryKey: ['wallet-balance'] });
|
|
qc.invalidateQueries({ queryKey: ['representation-summary'] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const submit = () => {
|
|
const n = Number(amount);
|
|
if (!n || n <= 0) { toast.error('مبلغ نامعتبر است'); return; }
|
|
if (n > balance) { toast.error('مبلغ بیشتر از موجودی قابل برداشت است'); return; }
|
|
if (!ibanId) { toast.error('انتخاب شماره شبا الزامی است'); return; }
|
|
createMut.mutate({ amount_rials: n, iban_id: ibanId });
|
|
};
|
|
|
|
const cards = [
|
|
{ label: 'موجودی قابل برداشت', value: formatRial(balance), color: 'var(--primary)' },
|
|
{ label: 'مجموع تسویهشده', value: formatRial(income?.settled_rials ?? 0), color: 'var(--success)' },
|
|
{ label: 'در انتظار تسویه', value: formatRial(income?.pending_rials ?? 0), color: 'var(--warning)' },
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">تسویه حساب</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درخواست برداشت از کیفپول نماینده</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
|
{cards.map(c => (
|
|
<div key={c.label} className="stat" style={{ background: 'var(--surface-3)' }}>
|
|
<div className="lbl">{c.label}</div>
|
|
<div className="val" style={{ color: c.color, fontSize: 15 }}>{c.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
|
<h3 style={{ fontSize: 16 }}>ثبت درخواست جدید</h3>
|
|
</div>
|
|
{ibans.length === 0 ? (
|
|
<div className="muted" style={{ fontSize: 13 }}>
|
|
برای ثبت درخواست تسویه، ابتدا در{' '}
|
|
<a href="/admin/representation-profile" style={{ color: 'var(--primary)', fontWeight: 600 }}>پروفایل</a>{' '}
|
|
یک شماره شبای تأییدشده اضافه کنید.
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<input
|
|
type="number" min={0} dir="ltr" value={amount} placeholder="مبلغ به ریال"
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }}
|
|
/>
|
|
<select
|
|
value={ibanId} onChange={(e) => setIbanId(e.target.value)} dir="ltr"
|
|
style={{ width: 320, height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 12.5, boxSizing: 'border-box' }}
|
|
>
|
|
<option value="">انتخاب شماره شبا...</option>
|
|
{ibans.map((b) => (
|
|
<option key={b.id} value={b.id}>{b.iban}{b.bank_name ? ` — ${b.bank_name}` : ''}</option>
|
|
))}
|
|
</select>
|
|
<button className="btn primary" onClick={submit} disabled={createMut.isPending}>
|
|
{createMut.isPending ? 'در حال ثبت...' : 'ثبت درخواست'}
|
|
</button>
|
|
<span className="muted" style={{ fontSize: 12 }}>حداکثر: {formatRial(balance)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
|
<h3 style={{ fontSize: 16 }}>درخواستهای قبلی</h3>
|
|
</div>
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="tbl" style={{ width: '100%' }}>
|
|
<thead><tr><th>مبلغ</th><th>وضعیت</th><th>توضیح مدیر</th><th>تاریخ</th></tr></thead>
|
|
<tbody>
|
|
{settlements.length === 0 && (
|
|
<tr><td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 16 }}>درخواستی ثبت نشده است</td></tr>
|
|
)}
|
|
{settlements.map(s => (
|
|
<tr key={s.uuid}>
|
|
<td>{formatRial(s.amount_rials)}</td>
|
|
<td><span className={`badge ${STATUS_CLASS[s.status] ?? 'gray'}`}>{STATUS_LABEL[s.status] ?? s.status}</span></td>
|
|
<td>{s.admin_note ?? '—'}</td>
|
|
<td>{formatDate(s.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination page={page} total={settlementsTotal} limit={limit} onPageChange={setPage} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|