- 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.
201 lines
8.1 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|