Files
clinicpro/assets/admin/pages/MyPaymentsPage.tsx
T
hamed ecdefa3c24 feat(tour): add onboarding tours for various admin pages
- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience.
- Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel.
- Updated documentation to reflect the addition of tours and their implementation details.
2026-08-10 10:10:20 +03:30

201 lines
8.1 KiB
TypeScript

import { useNavigate, useSearchParams } from 'react-router';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import SearchableSelect from '../components/ui/SearchableSelect';
import StatCard from '../components/ui/StatCard';
import StatusBadge from '../components/ui/StatusBadge';
import DataTable, { type Column } from '../components/ui/DataTable';
import { formatRial, formatDate, formatNumber, toDate, toGregorianDate, todayIso } from '../lib/utils';
import {
usePayments,
usePaymentsSummary,
MY_PAYMENTS_LIMIT,
type PaymentRow,
} from '../hooks/useMyPayments';
const STATUS_OPTIONS = [
{ value: '', label: 'همه وضعیت‌ها' },
{ value: 'paid', label: 'پرداخت شده' },
{ value: 'partial', label: 'پرداخت ناقص' },
{ value: 'unsettled', label: 'تسویه نشده' },
];
const isoNDaysAgo = (days: number): string => {
const d = new Date();
d.setDate(d.getDate() - days);
return toGregorianDate(d);
};
/** HH:MM (Persian digits) from a unix timestamp. */
function formatTime(unix: number): string {
return new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(unix * 1000));
}
/** unix start-of-day for `from`, end-of-day for `to`, from a gregorian Y-m-d. */
function dayBound(value: string, end: boolean): number | undefined {
const d = toDate(value);
if (!d) return undefined;
const secs = Math.floor(d.setHours(0, 0, 0, 0) / 1000);
return end ? secs + 86399 : secs;
}
function Avatar({ name }: { name: string | null }) {
return (
<div style={{
width: 32, height: 32, borderRadius: '50%', flexShrink: 0,
background: 'linear-gradient(145deg, var(--primary), var(--primary-700, var(--primary)))',
display: 'grid', placeItems: 'center', color: 'var(--on-primary)', fontSize: 13, fontWeight: 700,
}}>
{(name ?? '؟').charAt(0)}
</div>
);
}
/**
* لیست پرداخت‌ها — فهرست تخت صورتحساب‌های ثبت‌شده‌ی tenant.
* فیلترها در query string زندگی می‌کنند تا رفرش و اشتراک لینک، نما را حفظ کند.
*/
export default function MyPaymentsPage() {
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const search = params.get('search') ?? '';
const status = params.get('status') ?? '';
const from = params.get('from') ?? '';
const to = params.get('to') ?? '';
const page = Math.max(1, Number(params.get('page') ?? 1));
const hasFilters = !!(search || status || from || to);
/** تغییر فیلتر همیشه به صفحه‌ی اول برمی‌گردد؛ ماندن روی صفحه ۵ با نتیجه‌ی جدید بی‌معناست. */
const setParam = (patch: Record<string, string>) => {
const next = new URLSearchParams(params);
Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k)));
if (!('page' in patch)) next.delete('page');
setParams(next, { replace: true });
};
const filters = {
national_code: search || undefined,
status: status || undefined,
from: from ? dayBound(from, false) : undefined,
to: to ? dayBound(to, true) : undefined,
};
const listQuery = usePayments({ page, ...filters });
const summaryQuery = usePaymentsSummary(filters);
const rows = listQuery.data?.data ?? [];
const total = listQuery.data?.meta?.totalRecords ?? 0;
const summary = summaryQuery.data?.data;
const columns: Column<PaymentRow>[] = [
{
key: 'patient_name',
header: 'بیمار',
render: (r) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar name={r.patient_name} />
<span style={{ fontWeight: 600 }}>{r.patient_name ?? '—'}</span>
</div>
),
},
{ key: 'national_code', header: 'کد ملی', render: (r) => <span dir="ltr">{r.national_code ?? '—'}</span> },
{
key: 'issued_at',
header: 'تاریخ',
render: (r) => <span dir="ltr">{formatDate(r.issued_at)} - {formatTime(r.issued_at)}</span>,
},
{
key: 'amount_rials',
header: 'مبلغ',
render: (r) => (
<div>
<div style={{ fontWeight: 600 }}>{formatRial(r.amount_rials)}</div>
{r.status === 'partial' && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پرداخت‌شده: {formatRial(r.paid_rials)}
</div>
)}
</div>
),
},
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
];
return (
<>
<PageHeader
title="لیست پرداخت‌ها"
tourId="my-payments"
description="پرداخت‌های ثبت‌شده‌ی بیماران شما"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداخت‌ها' }]}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard tone="violet" label="مجموع صورتحساب‌ها" value={formatRial(summary?.total_rials ?? 0)} />
<StatCard tone="green" label="پرداخت‌شده" value={formatRial(summary?.paid_rials ?? 0)} />
<StatCard tone="pink" label="تسویه‌نشده" value={formatRial(summary?.unsettled_rials ?? 0)} />
<StatCard tone="amber" label="تعداد صورتحساب" value={formatNumber(summary?.invoices_count ?? 0)} />
</div>
<div className="card" style={{ padding: 18 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', marginBottom: 16 }}>
<div style={{ minWidth: 150 }}>
<label className="field-label">وضعیت</label>
<SearchableSelect
options={STATUS_OPTIONS}
value={status}
onChange={(v) => setParam({ status: v ? String(v) : '' })}
placeholder="همه وضعیت‌ها"
height={38}
/>
</div>
<div style={{ minWidth: 140 }}>
<label className="field-label">از تاریخ</label>
<PersianDatePicker value={from} onChange={(v) => setParam({ from: v })} height={38} />
</div>
<div style={{ minWidth: 140 }}>
<label className="field-label">تا تاریخ</label>
<PersianDatePicker value={to} onChange={(v) => setParam({ to: v })} height={38} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(30), to: todayIso() })}>یک ماه اخیر</button>
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(365), to: todayIso() })}>یک سال اخیر</button>
{hasFilters && (
<button
className="btn ghost sm"
onClick={() => setParams(new URLSearchParams(), { replace: true })}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<ArrowPathIcon style={{ width: 13 }} /> پاک‌کردن فیلترها
</button>
)}
</div>
</div>
<DataTable
columns={columns}
data={rows}
loading={listQuery.isLoading}
searchValue={search}
onSearchChange={(v) => setParam({ search: v.replace(/\D/g, '') })}
searchPlaceholder="کد ملی بیمار"
emptyMessage="پرداختی ثبت نشده است."
actions={(row) => (
<button className="btn primary sm" onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}>
جزئیات
</button>
)}
/>
{total > MY_PAYMENTS_LIMIT && (
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={(p) => setParam({ page: String(p) })} />
)}
</div>
</>
);
}