Files
clinicpro/assets/admin/pages/SettlementDetailPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

258 lines
12 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router';
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 BackButton from '../components/ui/BackButton';
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),
});
// آپلود رسید (بدون نهایی‌سازی). نهایی‌سازی با دکمه‌ی جداگانه‌ی «تکمیل» انجام می‌شود.
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('آپلود رسید ناموفق بود');
// رسید را در همان رکورد ذخیره می‌کنیم تا دکمه‌ی «تکمیل» فعال شود (وضعیت همچنان approved).
await api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/receipt`, { receipt: url });
toast.success('رسید آپلود شد');
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { toast.error(e?.message ?? 'خطا در آپلود رسید'); }
finally { setPaying(false); }
};
// تکمیل: نهایی‌سازی پرداخت (وضعیت → paid). مبلغ قبلاً هنگام درخواست از کیف‌پول کسر شده.
const completeMut = useMutation({
mutationFn: () => api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/paid`, { receipt: s?.receipt }),
onSuccess: () => {
toast.success('تسویه تکمیل شد');
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
qc.invalidateQueries({ queryKey: ['settlements'] });
},
onError: (e: Error) => toast.error(e.message),
});
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 }}>
<BackButton fallback="/admin/settlements" />
<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, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<input ref={receiptInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleReceiptUpload(e.target.files[0])} />
<button className="btn ghost sm" disabled={paying}
onClick={() => receiptInputRef.current?.click()}>
<ArrowUpTrayIcon style={{ width: 15, height: 15 }} />
{paying ? 'در حال آپلود...' : (s.receipt ? 'تغییر رسید' : 'آپلود رسید')}
</button>
{/* دکمه‌ی تکمیل فقط بعد از آپلود رسید فعال می‌شود */}
<button className="btn primary sm" disabled={!s.receipt || completeMut.isPending}
onClick={() => completeMut.mutate()}
title={!s.receipt ? 'ابتدا رسید را آپلود کنید' : undefined}>
<CheckIcon style={{ width: 15, height: 15 }} />
{completeMut.isPending ? 'در حال تکمیل...' : 'تکمیل'}
</button>
<p className="muted" style={{ fontSize: 12, width: '100%' }}>
{s.receipt
? 'با زدن «تکمیل»، وضعیت به «پرداخت شده» تغییر می‌کند. مبلغ هنگام ثبت درخواست از کیف‌پول کسر شده است.'
: 'ابتدا رسید پرداخت را آپلود کنید، سپس دکمه‌ی «تکمیل» فعال می‌شود.'}
</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>
);
}