- Updated date handling in BlogSeoFields and ScheduleSection to use Tehran timezone utilities for consistency. - Introduced `toTehranClockTime`, `tehranWallClockToUnix`, and `todayIso` functions for accurate date representation. - Modified various components to utilize these new utilities, ensuring that date strings are correctly formatted and timestamps are accurately converted. - Enhanced API documentation to clarify the handling of date fields, emphasizing the importance of server-local midnight. - Added tests to verify that date overrides and holidays maintain the correct day without shifting due to timezone discrepancies.
163 lines
6.9 KiB
TypeScript
163 lines
6.9 KiB
TypeScript
import React from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import type { Payment } from '../types';
|
|
import { formatDateTime, formatRial } from '../lib/utils';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import StatusBadge from '../components/ui/StatusBadge';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
|
|
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
|
appointment: 'نوبت',
|
|
subscription: 'اشتراک',
|
|
sms_wallet: 'کیف پول پیامک',
|
|
};
|
|
|
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<div className="cp-info-row">
|
|
<span className="cp-info-label text-sm">{label}</span>
|
|
<span className="cp-info-value">{value ?? '—'}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function PaymentDetailPage() {
|
|
const { uuid } = useParams<{ uuid: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['payment', uuid],
|
|
queryFn: () => api.get<ApiResponse<Payment>>(`/api/v1/admin/payments/${uuid}`),
|
|
enabled: !!uuid,
|
|
});
|
|
|
|
const payment = data?.data;
|
|
const queryClient = useQueryClient();
|
|
const [confirm, setConfirm] = React.useState<null | 'refund' | 'reverse'>(null);
|
|
|
|
const action = useMutation({
|
|
mutationFn: (kind: 'refund' | 'reverse') =>
|
|
api.post<ApiResponse<{ status: string }>>(`/api/v1/admin/payments/${uuid}/${kind}`, {}),
|
|
onSuccess: (_res, kind) => {
|
|
toast.success(kind === 'refund' ? 'درخواست استرداد وجه ثبت شد' : 'برگشت وجه انجام شد');
|
|
queryClient.invalidateQueries({ queryKey: ['payment', uuid] });
|
|
setConfirm(null);
|
|
},
|
|
onError: (e: any) => {
|
|
toast.error(e?.message || 'عملیات ناموفق بود');
|
|
setConfirm(null);
|
|
},
|
|
});
|
|
|
|
const canRefund = payment?.status === 'success' && payment?.gateway === 'mellat';
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="جزئیات پرداخت"
|
|
breadcrumbs={[
|
|
{ label: 'داشبورد', to: '/admin/dashboard' },
|
|
{ label: 'پرداختها', to: '/admin/payments' },
|
|
{ label: 'جزئیات' },
|
|
]}
|
|
action={
|
|
<button onClick={() => navigate('/admin/payments')}
|
|
className="flex items-center gap-2 text-sm text-[var(--text-2)] hover:text-[var(--text)] transition-colors">
|
|
<ArrowRightIcon className="w-4 h-4" />
|
|
بازگشت
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{isLoading ? (
|
|
<div className="cp-card p-6 space-y-3">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-8 rounded-lg skeleton" />
|
|
))}
|
|
</div>
|
|
) : payment ? (
|
|
<div className="max-w-lg">
|
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
|
<InfoRow label="شناسه" value={<span dir="ltr" className="font-mono text-xs">{payment.uuid}</span>} />
|
|
<InfoRow label="شماره سفارش" value={payment.order_id ? <span dir="ltr" className="font-mono text-xs">{payment.order_id}</span> : null} />
|
|
<InfoRow label="بیمار" value={payment.patient_name} />
|
|
<InfoRow label="موبایل" value={<span dir="ltr">{payment.patient_mobile}</span>} />
|
|
<InfoRow label="نوع" value={PAYMENT_TYPE_LABELS[payment.type ?? ''] ?? payment.type} />
|
|
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
|
|
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
|
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
|
<InfoRow label="شماره مرجع" value={payment.ref_id ? <span dir="ltr" className="font-mono text-xs">{payment.ref_id}</span> : null} />
|
|
<InfoRow label="شماره کارت" value={payment.card_pan ? <span dir="ltr" className="font-mono text-xs">{payment.card_pan}</span> : null} />
|
|
<InfoRow label="تاریخ پرداخت" value={formatDateTime(payment.paid_at)} />
|
|
<InfoRow label="تاریخ ثبت" value={formatDateTime(payment.created_at)} />
|
|
{payment.appointment_uuid && (
|
|
<InfoRow
|
|
label="نوبت مرتبط"
|
|
value={
|
|
<a href={`/admin/appointments/${payment.appointment_uuid}`}
|
|
className="text-[var(--primary)] hover:underline text-xs font-mono">
|
|
مشاهده نوبت
|
|
</a>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{payment.refunds && payment.refunds.length > 0 && (
|
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6 mt-4">
|
|
<h3 className="text-sm font-semibold mb-3">استردادها</h3>
|
|
{payment.refunds.map((r, i) => (
|
|
<InfoRow
|
|
key={i}
|
|
label={formatDateTime(r.at)}
|
|
value={
|
|
<span>
|
|
{formatRial(r.amount)}
|
|
{r.ref && <span dir="ltr" className="font-mono text-xs text-[var(--text-3)]"> ({r.ref})</span>}
|
|
</span>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{canRefund && (
|
|
<div className="flex gap-3 mt-4">
|
|
<button onClick={() => setConfirm('refund')} className="btn danger">
|
|
استرداد وجه
|
|
</button>
|
|
<button onClick={() => setConfirm('reverse')} className="btn">
|
|
برگشت وجه
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={confirm !== null}
|
|
danger
|
|
loading={action.isPending}
|
|
title={confirm === 'reverse' ? 'برگشت وجه' : 'استرداد وجه'}
|
|
message={
|
|
confirm === 'reverse'
|
|
? 'کل مبلغ این تراکنش برگشت داده میشود. ادامه میدهید؟'
|
|
: 'درخواست استرداد کل مبلغ به کارت پرداختکننده ثبت میشود (عودت نهایی ممکن است چند روز طول بکشد). ادامه میدهید؟'
|
|
}
|
|
confirmLabel={confirm === 'reverse' ? 'برگشت وجه' : 'استرداد وجه'}
|
|
onConfirm={() => confirm && action.mutate(confirm)}
|
|
onCancel={() => setConfirm(null)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] p-16 text-center text-[var(--text-3)]">
|
|
پرداختی یافت نشد
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|