Align /admin/my-payments with the tauri /payments source (per user): the list
is now a flat, newest-first list of the tenant's recorded invoices — one row
per invoice — instead of the per-patient aggregation built from Figma.
Backend:
- replace InvoiceRepository::patientPaymentSummary aggregation with
tenantInvoices/countTenantInvoices (flat, joins patient name/national code).
- InvoiceService::patientPaymentList → tenantInvoiceList.
- BillingController: GET /api/v1/my/billing/patient-payments →
GET /api/v1/my/billing/payments returning
{ invoice_uuid, patient_uuid, patient_name, national_code, issued_at,
amount_rials, status } rows.
- node-2 patient invoices endpoint unchanged.
Frontend:
- useMyPayments: usePatientPayments → usePayments (flat PaymentRow).
- MyPaymentsPage columns match tauri DetailT: row #, avatar+name, national
code, date-time, amount paid, مشاهده (no status column); 'اضافه کردن بیمار'
links to /admin/patients/new. Filters (national code / status / Jalali date
range) kept.
Tests + docs/api/billing.md updated. Intentionally omitted tauri extras:
mobile Cards view and the advanced ModalFilter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
7.5 KiB
TypeScript
172 lines
7.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { MagnifyingGlassIcon, EyeIcon, BanknotesIcon, UserPlusIcon } from '@heroicons/react/24/outline';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
|
import { formatRial, formatDate, formatNumber, toDate } from '../lib/utils';
|
|
import {
|
|
usePayments,
|
|
MY_PAYMENTS_LIMIT,
|
|
type PaymentRow,
|
|
} from '../hooks/useMyPayments';
|
|
|
|
const EMPTY: PaymentRow[] = [];
|
|
|
|
/** 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: '#fff', fontSize: 13, fontWeight: 700,
|
|
}}>
|
|
{(name ?? '؟').charAt(0)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** لیست پرداختها — flat list of the tenant's recorded invoices (ported from tauri /payments). */
|
|
export default function MyPaymentsPage() {
|
|
const navigate = useNavigate();
|
|
const [page, setPage] = useState(1);
|
|
const [nationalCode, setNationalCode] = useState('');
|
|
const [status, setStatus] = useState('');
|
|
const [from, setFrom] = useState('');
|
|
const [to, setTo] = useState('');
|
|
|
|
const reset = () => setPage(1);
|
|
|
|
const { data, isLoading } = usePayments({
|
|
page,
|
|
national_code: nationalCode.trim() || undefined,
|
|
status: status || undefined,
|
|
from: from ? dayBound(from, false) : undefined,
|
|
to: to ? dayBound(to, true) : undefined,
|
|
});
|
|
|
|
const rows = data?.data ?? EMPTY;
|
|
const total = data?.meta?.totalRecords ?? 0;
|
|
|
|
const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
|
|
const td: React.CSSProperties = { padding: '12px 16px' };
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="لیست پرداختها"
|
|
description="پرداختهای ثبتشدهی بیماران شما"
|
|
action={
|
|
<button className="btn primary sm" onClick={() => navigate('/admin/patients/new')}>
|
|
<UserPlusIcon style={{ width: 16 }} /> اضافه کردن بیمار
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{/* فیلترها — کد ملی + وضعیت + بازهی تاریخ */}
|
|
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<div style={{
|
|
flex: '1 1 240px', minWidth: 200, display: 'flex', alignItems: 'center', gap: 8,
|
|
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '9px 12px',
|
|
}}>
|
|
<MagnifyingGlassIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />
|
|
<input
|
|
style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontSize: 14 }}
|
|
value={nationalCode}
|
|
onChange={(e) => { setNationalCode(e.target.value.replace(/\D/g, '')); reset(); }}
|
|
placeholder="کد ملی بیمار را وارد کنید..."
|
|
dir="ltr"
|
|
inputMode="numeric"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
className="input"
|
|
style={{ flex: '0 0 auto', width: 160 }}
|
|
value={status}
|
|
onChange={(e) => { setStatus(e.target.value); reset(); }}
|
|
aria-label="وضعیت"
|
|
>
|
|
<option value="">همه وضعیتها</option>
|
|
<option value="paid">پرداخت شده</option>
|
|
<option value="unsettled">تسویه نشده</option>
|
|
</select>
|
|
|
|
<div style={{ width: 150 }}>
|
|
<PersianDateInput value={from} onChange={(v) => { setFrom(v); reset(); }} placeholder="از تاریخ" />
|
|
</div>
|
|
<div style={{ width: 150 }}>
|
|
<PersianDateInput value={to} onChange={(v) => { setTo(v); reset(); }} placeholder="تا تاریخ" />
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
|
) : rows.length === 0 ? (
|
|
<div className="card" style={{ padding: '60px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
|
<BanknotesIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
|
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 6, color: 'var(--text-2)' }}>پرداختی یافت نشد</div>
|
|
<div style={{ fontSize: 13 }}>با ثبت صورتحساب برای بیماران، این فهرست پر میشود.</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="card" style={{ overflowX: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 12 }}>
|
|
<th style={th}>ردیف</th>
|
|
<th style={th}>نام بیمار</th>
|
|
<th style={th}>کد ملی</th>
|
|
<th style={th}>تاریخ</th>
|
|
<th style={th}>مبلغ پرداختشده</th>
|
|
<th style={{ ...th, textAlign: 'left' }}>عملیات</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={row.invoice_uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
<td style={td}>{formatNumber((page - 1) * MY_PAYMENTS_LIMIT + i + 1)}</td>
|
|
<td style={td}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<Avatar name={row.patient_name} />
|
|
<span style={{ fontWeight: 600 }}>{row.patient_name ?? '—'}</span>
|
|
</div>
|
|
</td>
|
|
<td style={{ ...td, color: 'var(--text-2)' }} dir="ltr">{row.national_code ?? '—'}</td>
|
|
<td style={td} dir="ltr">{formatDate(row.issued_at)} - {formatTime(row.issued_at)}</td>
|
|
<td style={{ ...td, fontWeight: 600 }}>{formatRial(row.amount_rials)}</td>
|
|
<td style={{ ...td, textAlign: 'left' }}>
|
|
<button
|
|
className="cp-btn-secondary"
|
|
style={{ height: 32, padding: '0 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
|
|
onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}
|
|
>
|
|
<EyeIcon style={{ width: 15 }} /> مشاهده
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div style={{ marginTop: 16 }}>
|
|
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={setPage} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|