Files
clinicpro/assets/admin/pages/SettlementDetailPage.tsx
T

237 lines
11 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowRightIcon, CheckIcon, XMarkIcon, ArrowUpTrayIcon, DocumentTextIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import { formatDateTime, formatRial } from '../lib/utils';
import StatusBadge from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
interface SettlementDetail {
uuid: string;
representation_name: string;
representation_mobile: string | null;
amount: number;
status: 'pending' | 'approved' | 'rejected' | 'paid';
bank_card: string | null;
bank_name: string | null;
bank_iban: string | null;
bank_owner: string | null;
reject_reason: string | null;
receipt: string | null;
requested_at: string;
processed_at: string | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderBottom: '1px solid var(--border)', gap: 12 }}>
<span className="muted" style={{ fontSize: 13 }}>{label}</span>
<span style={{ fontSize: 13.5, fontWeight: 500, textAlign: 'left' }}>{value}</span>
</div>
);
}
export default function SettlementDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const authToken = useAuthStore(s => s.token);
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [paying, setPaying] = useState(false);
const receiptInputRef = React.useRef<HTMLInputElement>(null);
const { data, isLoading } = useQuery({
queryKey: ['settlement-detail', uuid],
queryFn: () => api.get<ApiResponse<SettlementDetail>>(`/api/v1/admin/settlement/${uuid}`),
enabled: !!uuid,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s: SettlementDetail | undefined = (data?.data as any)?.data ?? data?.data;
const approveMut = useMutation({
mutationFn: () => api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/approve`, {}),
onSuccess: () => {
toast.success('تسویه تأیید شد');
setApproveOpen(false);
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
qc.invalidateQueries({ queryKey: ['settlements'] });
},
onError: (e: Error) => toast.error(e.message),
});
const rejectMut = useMutation({
mutationFn: () => api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/reject`, { reason: rejectReason }),
onSuccess: () => {
toast.success('تسویه رد شد');
setRejectOpen(false);
setRejectReason('');
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
qc.invalidateQueries({ queryKey: ['settlements'] });
},
onError: (e: Error) => toast.error(e.message),
});
// آپلود رسید → سپس ثبت پرداخت نهایی (paid). مبلغ قبلاً هنگام درخواست از کیف‌پول کسر شده.
const handleReceiptUpload = async (file: File) => {
setPaying(true);
try {
const res = await fetch('/file/upload/clinic_pro/settlement/receipt', {
method: 'POST',
headers: {
'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${authToken ?? ''}`,
},
body: file,
});
const json = await res.json();
const url = json?.data?.url;
if (!url) throw new Error('آپلود رسید ناموفق بود');
await api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/paid`, { receipt: url });
toast.success('پرداخت ثبت شد');
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
qc.invalidateQueries({ queryKey: ['settlements'] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { toast.error(e?.message ?? 'خطا در ثبت پرداخت'); }
finally { setPaying(false); }
};
if (isLoading) {
return (
<div className="fade-in">
<div className="card card-pad"><div className="skeleton" style={{ height: 300, borderRadius: 'var(--r)' }} /></div>
</div>
);
}
if (!s) {
return (
<div className="fade-in">
<div className="card card-pad" style={{ textAlign: 'center', padding: 40 }}>
<p className="muted">درخواست تسویه یافت نشد</p>
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => navigate('/admin/settlements')}>
بازگشت به لیست
</button>
</div>
</div>
);
}
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button className="btn ghost sm" onClick={() => navigate('/admin/settlements')} style={{ padding: '6px 10px' }}>
<ArrowRightIcon style={{ width: 15, height: 15 }} /> تسویه‌حساب‌ها
</button>
<div>
<h1 className="section-title">جزئیات تسویه</h1>
<div className="muted" style={{ fontSize: 13 }}>{s.representation_name}</div>
</div>
</div>
{s.status === 'pending' && (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary sm" onClick={() => setApproveOpen(true)}>
<CheckIcon style={{ width: 15, height: 15 }} /> تأیید
</button>
<button className="btn danger sm" onClick={() => setRejectOpen(true)}>
<XMarkIcon style={{ width: 15, height: 15 }} /> رد
</button>
</div>
)}
</div>
<div className="card card-pad" style={{ maxWidth: 560 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--primary)' }}>{formatRial(s.amount)}</span>
<StatusBadge type="settlement" value={s.status} />
</div>
<Row label="نماینده" value={s.representation_name} />
<Row label="موبایل" value={s.representation_mobile ? <span dir="ltr">{s.representation_mobile}</span> : '—'} />
<Row label="صاحب حساب" value={s.bank_owner ?? '—'} />
<Row label="بانک" value={s.bank_name ?? '—'} />
<Row label="شماره کارت" value={s.bank_card ? <span dir="ltr" style={{ fontFamily: 'monospace' }}>{s.bank_card}</span> : '—'} />
<Row label="شبا" value={s.bank_iban ? <span dir="ltr" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.bank_iban}</span> : '—'} />
<Row label="تاریخ درخواست" value={formatDateTime(s.requested_at)} />
<Row label="تاریخ بررسی" value={s.processed_at ? formatDateTime(s.processed_at) : '—'} />
{s.reject_reason && <Row label="یادداشت/دلیل رد" value={s.reject_reason} />}
</div>
{/* رسید پرداخت */}
{(s.status === 'approved' || s.status === 'paid' || s.receipt) && (
<div className="card card-pad" style={{ maxWidth: 560, marginTop: 'var(--gap)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<DocumentTextIcon style={{ width: 18, height: 18, color: 'var(--text-3)' }} />
<b style={{ fontSize: 14 }}>رسید پرداخت</b>
</div>
{s.receipt ? (
<a href={s.receipt} target="_blank" rel="noopener noreferrer"
style={{ display: 'block', maxWidth: 280 }}>
<img src={s.receipt} alt="رسید"
style={{ width: '100%', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }} />
<span className="muted" style={{ fontSize: 12, display: 'block', marginTop: 6, color: 'var(--primary)' }}>
مشاهده‌ی اندازه‌ی کامل
</span>
</a>
) : (
<p className="muted" style={{ fontSize: 13 }}>هنوز رسیدی آپلود نشده است.</p>
)}
{s.status === 'approved' && (
<div style={{ marginTop: 14 }}>
<input ref={receiptInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleReceiptUpload(e.target.files[0])} />
<button className="btn primary sm" disabled={paying}
onClick={() => receiptInputRef.current?.click()}>
<ArrowUpTrayIcon style={{ width: 15, height: 15 }} />
{paying ? 'در حال ثبت...' : 'آپلود رسید و ثبت پرداخت'}
</button>
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
با آپلود رسید، وضعیت به «پرداخت شده» تغییر می‌کند. مبلغ هنگام ثبت درخواست از کیف‌پول کسر شده است.
</p>
</div>
)}
</div>
)}
<ConfirmDialog
open={approveOpen}
title="تأیید تسویه"
message={`تسویه ${s.representation_name} به مبلغ ${formatRial(s.amount)} را تأیید می‌کنید؟`}
confirmLabel="تأیید"
loading={approveMut.isPending}
onConfirm={() => approveMut.mutate()}
onCancel={() => setApproveOpen(false)}
/>
<Modal
open={rejectOpen}
title="رد درخواست تسویه"
onClose={() => { setRejectOpen(false); setRejectReason(''); }}
footer={
<>
<button onClick={() => { setRejectOpen(false); setRejectReason(''); }} className="btn ghost sm">لغو</button>
<button onClick={() => rejectMut.mutate()} disabled={!rejectReason || rejectMut.isPending} className="btn danger sm">
{rejectMut.isPending ? 'در حال ارسال...' : 'رد کردن'}
</button>
</>
}
>
<div className="form-row">
<label>دلیل رد</label>
<textarea value={rejectReason} onChange={(e) => setRejectReason(e.target.value)} rows={4}
placeholder="دلیل رد درخواست را بنویسید..." className="input" style={{ resize: 'none', height: 'auto' }} />
</div>
</Modal>
</div>
);
}