feat: port پرداخت‌ها (payments) tab from tauri to patient detail page

Replace the flat gateway-payment list on the patient detail «پرداخت‌ها» tab with
the session-grouped accordion design ported from clinic-pro-tauri PaymentsSection:

- New SessionPaymentAccordion mirrors the tauri accordion: header (service, date,
  final price, پرداخت شده/تسویه نشده badge) + a settlement line (date, amount,
  method, personnel=doctor) or the «هیچ پرداختی ثبت نشده است.» empty message.
- New PaymentsTab reuses the already-fetched sessions query (real model = one
  payment_method per session) — no extra request, no backend change, no new API.
- Add FilesServicePaymentsCheck icon (verbatim from tauri).
- Remove the now-unused paymentsQ (gateway list), PAYMENT_STATUS map and the dead
  TabList helper (both its callers replaced by the ported card/accordion tabs).
- Tests: SessionPaymentAccordion (paid/unpaid/collapsed/toggle) + updated the page
  payments-tab test to assert session accordions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 11:40:51 +03:30
co-authored by Claude Opus 4.8
parent b82ab4db9c
commit 069189863c
5 changed files with 196 additions and 40 deletions
@@ -146,12 +146,16 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
expect(link).toHaveAttribute('href', '/admin/patients/r1/session/new');
});
it('lists patient payments with status label on the payments tab', async () => {
it('groups payments by مراجعه (session) accordions on the پرداخت‌ها tab', async () => {
renderDetail();
await loaded();
fireEvent.click(screen.getByText('پرداخت‌ها'));
expect(await screen.findByText('موفق')).toBeInTheDocument();
expect(screen.getByText(/mellat/)).toBeInTheDocument();
// settlement badges from the two seeded sessions (s1 unpaid, s2 paid)
expect(await screen.findByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
expect(screen.getByText('روکش')).toBeInTheDocument(); // paid session header
// first (unpaid) panel is open by default → empty settlement message
expect(screen.getByText('هیچ پرداختی ثبت نشده است.')).toBeInTheDocument();
});
it('shows wallet balance on the wallet tab', async () => {
+30 -37
View File
@@ -21,6 +21,7 @@ import PriceInput from '../components/ui/PriceInput';
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
import SessionPaymentAccordion, { type SessionPaymentData } from '../components/SessionPaymentAccordion';
import InvoiceSummaryModal from '../components/InvoiceSummaryModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons';
@@ -47,10 +48,6 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
{ key: 'records', label: 'پرونده پزشکی', icon: (c) => <TabBody color={c} /> },
];
const PAYMENT_STATUS: Record<string, string> = {
pending: 'در انتظار', success: 'موفق', failed: 'ناموفق', canceled: 'لغو شده', refunded: 'بازگشت',
};
function Placeholder({ label }: { label: string }) {
return (
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
@@ -134,12 +131,6 @@ export default function PatientDetailPage() {
queryFn: () => api.get(`/api/v1/patient/${uuid}/appointments`),
enabled: !!uuid,
});
const paymentsQ = useQuery<ApiResponse<any[]>>({
queryKey: ['patient-payments', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/payments`),
enabled: !!uuid && tab === 'payments',
});
const sessions = sessionsQ.data?.data ?? [];
const hasDebt = sessions.some((s) => !s.is_paid);
const nowSec = Math.floor(Date.now() / 1000);
@@ -235,8 +226,7 @@ export default function PatientDetailPage() {
) : tab === 'appointments' ? (
<AppointmentsTab uuid={uuid!} q={appointmentsQ} />
) : tab === 'payments' ? (
<TabList q={paymentsQ} emptyLabel="پرداختی ثبت نشده است"
row={(p) => ({ title: formatRial(p.amount_rials), meta: [p.created_at ? formatDate(p.created_at) : '', p.gateway].filter(Boolean).join(' · '), badge: PAYMENT_STATUS[p.status] || p.status })} />
<PaymentsTab q={sessionsQ} />
) : tab === 'wallet' ? (
<WalletTab uuid={uuid!} />
) : tab === 'callcenter' ? (
@@ -689,6 +679,34 @@ function WalletTab({ uuid }: { uuid: string }) {
);
}
/**
* پرداخت‌ها — settlement history grouped by مراجعه (session), ported from tauri
* PaymentsSection. Reuses the already-fetched sessions; each session is an
* accordion showing its settlement line or the empty message.
*/
function PaymentsTab({ q }: { q: { data?: ApiResponse<any[]>; isLoading: boolean } }) {
const items = (q.data?.data ?? []) as SessionPaymentData[];
// undefined = default (first panel open, matching tauri); null = user closed all.
const [expanded, setExpanded] = useState<string | null | undefined>(undefined);
const openUuid = expanded === undefined ? (items[0]?.uuid ?? null) : expanded;
if (q.isLoading) return <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
if (items.length === 0) return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>پرداختی ثبت نشده است</div>;
return (
<div>
{items.map((s) => (
<SessionPaymentAccordion
key={s.uuid}
session={s}
expanded={openUuid === s.uuid}
onToggle={() => setExpanded(openUuid === s.uuid ? null : s.uuid)}
/>
))}
</div>
);
}
const APPT_SORT_OPTS = [
{ value: 'newest', label: 'جدیدترین' },
{ value: 'oldest', label: 'قدیمی‌ترین' },
@@ -756,28 +774,3 @@ function AppointmentsTab({ uuid, q }: {
);
}
function TabList({ q, emptyLabel, row }: {
q: { data?: ApiResponse<any[]>; isLoading: boolean };
emptyLabel: string;
row: (item: any) => { title: string; meta?: string; badge?: string };
}) {
if (q.isLoading) return <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
const items = q.data?.data ?? [];
if (items.length === 0) return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>{emptyLabel}</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((item, i) => {
const r = row(item);
return (
<div key={item.uuid ?? i} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{r.title}</div>
{r.meta && <div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 2 }}>{r.meta}</div>}
</div>
{r.badge && <span className="badge gray" style={{ fontSize: 11 }}>{r.badge}</span>}
</div>
);
})}
</div>
);
}