237 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|