diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index ad5cce43..87b3ad28 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -38,6 +38,8 @@ import RepresentationFinancePage from './pages/RepresentationFinancePage'; import RepresentationProfilePage from './pages/RepresentationProfilePage'; import DoctorProfilePage from './pages/DoctorProfilePage'; import MyPatientsPage from './pages/MyPatientsPage'; +import MyPaymentsPage from './pages/MyPaymentsPage'; +import MyPaymentDetailPage from './pages/MyPaymentDetailPage'; import NewSessionPage from './pages/NewSessionPage'; import InsurancePricingPage from './pages/InsurancePricingPage'; import ClaimsPage from './pages/ClaimsPage'; @@ -197,6 +199,9 @@ export default function App() { {/* دکتر / منشی / کلینیک */} } /> + } /> + } /> + } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 307539c8..6780986f 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -213,6 +213,11 @@ function buildSections( label: "پرونده بیماران", feature: "patient_records", }, + { + to: "/admin/my-payments", + icon: CreditCardIcon, + label: "پرداخت‌ها", + }, { to: "/admin/claims", icon: DocumentTextIcon, @@ -278,6 +283,11 @@ function buildSections( label: "پرونده بیماران", feature: "patient_records", }, + { + to: "/admin/my-payments", + icon: CreditCardIcon, + label: "پرداخت‌ها", + }, { to: "/admin/claims", icon: DocumentTextIcon, @@ -338,6 +348,11 @@ function buildSections( label: "پرونده بیماران", feature: "patient_records", }, + { + to: "/admin/my-payments", + icon: CreditCardIcon, + label: "پرداخت‌ها", + }, { to: "/admin/insurance-pricing", icon: ShieldCheckIcon, diff --git a/assets/admin/hooks/useMyPayments.test.tsx b/assets/admin/hooks/useMyPayments.test.tsx new file mode 100644 index 00000000..e2b2c5cc --- /dev/null +++ b/assets/admin/hooks/useMyPayments.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import { usePatientPayments, usePatientInvoices } from './useMyPayments'; + +const get = api.get as ReturnType; + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +beforeEach(() => { + get.mockReset(); + get.mockResolvedValue({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } }); +}); + +describe('useMyPayments', () => { + it('builds the list URL with all active filters', async () => { + renderHook(() => usePatientPayments({ page: 2, national_code: '1744', status: 'paid', from: 100, to: 200 }), { wrapper }); + await waitFor(() => expect(get).toHaveBeenCalled()); + const url = get.mock.calls[0][0] as string; + expect(url).toContain('/api/v1/my/billing/patient-payments?'); + expect(url).toContain('page=2'); + expect(url).toContain('national_code=1744'); + expect(url).toContain('status=paid'); + expect(url).toContain('from=100'); + expect(url).toContain('to=200'); + }); + + it('omits empty filters from the list URL', async () => { + renderHook(() => usePatientPayments({ page: 1 }), { wrapper }); + await waitFor(() => expect(get).toHaveBeenCalled()); + const url = get.mock.calls[0][0] as string; + expect(url).not.toContain('national_code='); + expect(url).not.toContain('status='); + }); + + it('does not fetch invoices until a patient uuid is provided', async () => { + const { rerender } = renderHook(({ uuid }: { uuid?: string }) => usePatientInvoices(uuid, 1), { + wrapper, initialProps: { uuid: undefined as string | undefined }, + }); + expect(get).not.toHaveBeenCalled(); + + rerender({ uuid: 'abc' }); + await waitFor(() => expect(get).toHaveBeenCalled()); + expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices'); + }); +}); diff --git a/assets/admin/hooks/useMyPayments.ts b/assets/admin/hooks/useMyPayments.ts new file mode 100644 index 00000000..0475a105 --- /dev/null +++ b/assets/admin/hooks/useMyPayments.ts @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse, PaginatedResponse } from '../lib/api'; + +/** A derived per-patient payment status shown on the list (node 1). */ +export type PaymentRowStatus = 'paid' | 'unpaid' | 'unsettled'; + +export interface PatientPaymentRow { + patient_uuid: string; + patient_name: string | null; + national_code: string | null; + invoice_count: number; + paid_rials: number; + remaining_rials: number; + status: PaymentRowStatus; +} + +export interface PatientPaymentFilters { + page: number; + national_code?: string; + status?: string; // paid | unsettled | unpaid + from?: number; // unix seconds + to?: number; // unix seconds +} + +/** A single invoice status on the detail table (node 2) — two-state. */ +export type InvoiceRowStatus = 'paid' | 'unsettled'; + +export interface PatientInvoiceRow { + uuid: string; + number: number; + issued_at: number; + total_rials: number; + status: InvoiceRowStatus; + service_title: string | null; + items: Array<{ uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }>; +} + +export interface PatientInvoicesPayload { + patient: { uuid: string; name: string | null; national_code: string | null }; + data: PatientInvoiceRow[]; + meta: { totalRecords: number; totalPages: number; currentPage: number }; +} + +const LIMIT = 20; + +/** Node 1 — paginated per-patient payment summary for the current tenant. */ +export function usePatientPayments(filters: PatientPaymentFilters) { + const qs = new URLSearchParams({ page: String(filters.page), limit: String(LIMIT) }); + if (filters.national_code) qs.set('national_code', filters.national_code); + if (filters.status) qs.set('status', filters.status); + if (filters.from) qs.set('from', String(filters.from)); + if (filters.to) qs.set('to', String(filters.to)); + + return useQuery>({ + queryKey: ['patient-payments', filters], + queryFn: () => api.get(`/api/v1/my/billing/patient-payments?${qs.toString()}`), + }); +} + +/** Node 2 — a single patient's recorded invoices (header + paginated list). */ +export function usePatientInvoices(patientUuid: string | undefined, page: number) { + return useQuery>({ + queryKey: ['patient-invoices', patientUuid, page], + queryFn: () => api.get(`/api/v1/my/billing/patients/${patientUuid}/invoices?page=${page}&limit=${LIMIT}`), + enabled: !!patientUuid, + }); +} + +export const MY_PAYMENTS_LIMIT = LIMIT; diff --git a/assets/admin/pages/MyPaymentDetailPage.test.tsx b/assets/admin/pages/MyPaymentDetailPage.test.tsx new file mode 100644 index 00000000..6d52c5fe --- /dev/null +++ b/assets/admin/pages/MyPaymentDetailPage.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ api: { get: vi.fn() }, ApiError: class extends Error {} })); + +import { api } from '../lib/api'; +import MyPaymentDetailPage from './MyPaymentDetailPage'; + +const get = api.get as ReturnType; + +const PAYLOAD = { + patient: { uuid: 'abc', name: 'دنیا خلیلی', national_code: '1744023654' }, + data: [ + { uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, status: 'paid', + service_title: 'روکش دندان', + items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }] }, + { uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, status: 'unsettled', + service_title: 'طرح لبخند', items: [] }, + ], + meta: { totalRecords: 2, totalPages: 1, currentPage: 1 }, +}; + +function renderPage() { + return renderWithProviders( + + } /> + , + { route: '/admin/my-payments/abc' }, + ); +} + +beforeEach(() => { + get.mockReset(); + get.mockResolvedValue({ success: true, data: PAYLOAD }); +}); + +describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => { + it('renders the patient header and invoice rows', async () => { + renderPage(); + expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument(); + expect(screen.getByText(/1744023654/)).toBeInTheDocument(); + expect(screen.getByText('روکش دندان')).toBeInTheDocument(); + expect(screen.getByText('طرح لبخند')).toBeInTheDocument(); + expect(screen.getByText('پرداخت شده')).toBeInTheDocument(); + expect(screen.getByText('تسویه نشده')).toBeInTheDocument(); + }); + + it('fetches invoices for the patient uuid from the route', async () => { + renderPage(); + await screen.findByText('دنیا خلیلی'); + expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices'); + }); + + it('expands a row to show its line items on بیشتر', async () => { + renderPage(); + await screen.findByText('روکش دندان'); + fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[0]); + // the item breakdown appears (title repeated inside the expanded sub-row) + expect(await screen.findAllByText('روکش دندان')).toHaveLength(2); + }); + + it('shows an empty state when the patient has no invoices', async () => { + get.mockResolvedValue({ success: true, data: { patient: PAYLOAD.patient, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } } }); + renderPage(); + expect(await screen.findByText('صورتحسابی ثبت نشده است')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/MyPaymentDetailPage.tsx b/assets/admin/pages/MyPaymentDetailPage.tsx new file mode 100644 index 00000000..4dbd74cb --- /dev/null +++ b/assets/admin/pages/MyPaymentDetailPage.tsx @@ -0,0 +1,150 @@ +import { Fragment, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ChevronRightIcon, ChevronDownIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import PageHeader from '../components/ui/PageHeader'; +import Pagination from '../components/ui/Pagination'; +import { formatRial, formatDate, formatNumber } from '../lib/utils'; +import { + usePatientInvoices, + MY_PAYMENTS_LIMIT, + type PatientInvoiceRow, + type InvoiceRowStatus, +} from '../hooks/useMyPayments'; + +const STATUS_LABEL: Record = { paid: 'پرداخت شده', unsettled: 'تسویه نشده' }; +const STATUS_BADGE: Record = { paid: 'green', unsettled: 'amber' }; + +const EMPTY: PatientInvoiceRow[] = []; + +/** 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)); +} + +/** پرداخت‌های ثبت‌شده — a single patient's recorded invoices (node 2). */ +export default function MyPaymentDetailPage() { + const { patientUuid } = useParams<{ patientUuid: string }>(); + const navigate = useNavigate(); + const [page, setPage] = useState(1); + const [expanded, setExpanded] = useState(null); + + const { data, isLoading } = usePatientInvoices(patientUuid, page); + const payload = data?.data; + const patient = payload?.patient; + const rows = payload?.data ?? EMPTY; + const total = payload?.meta?.totalRecords ?? 0; + + const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 }; + const td: React.CSSProperties = { padding: '12px 16px' }; + + return ( + <> + + + + + } + /> + + {/* سربرگ بیمار */} +
+ + نام بیمار: {patient?.name ?? '—'} + + + کدملی: {patient?.national_code ?? '—'} + +
+ + {isLoading ? ( +
در حال بارگذاری…
+ ) : rows.length === 0 ? ( +
+
صورتحسابی ثبت نشده است
+
برای این بیمار هنوز پرداخت ثبت‌شده‌ای وجود ندارد.
+
+ ) : ( + <> +
+ + + + + + + + + + + + + + {rows.map((row) => { + const open = expanded === row.uuid; + return ( + + + + + + + + + + + {open && ( + + + + )} + + ); + })} + +
شماره صورتحسابتاریخساعتنوع خدمتمبلغ کل (تومان)وضعیتجزئیات
#{formatNumber(row.number)}{formatDate(row.issued_at)}{formatTime(row.issued_at)}{row.service_title ?? '—'}{formatRial(row.total_rials)} + {STATUS_LABEL[row.status]} + + +
+ {row.items.length === 0 ? ( + آیتمی ثبت نشده است. + ) : ( +
+ {row.items.map((it) => ( +
+ + {it.title}{it.quantity > 1 ? ` × ${formatNumber(it.quantity)}` : ''} + + {formatRial(it.total_rials)} +
+ ))} +
+ )} +
+
+
+ +
+ + )} + + ); +} diff --git a/assets/admin/pages/MyPaymentsPage.test.tsx b/assets/admin/pages/MyPaymentsPage.test.tsx new file mode 100644 index 00000000..15512573 --- /dev/null +++ b/assets/admin/pages/MyPaymentsPage.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +const navigate = vi.fn(); +vi.mock('react-router-dom', async (orig) => ({ + ...(await orig()), + useNavigate: () => navigate, +})); +vi.mock('../lib/api', () => ({ api: { get: vi.fn() }, ApiError: class extends Error {} })); + +import { api } from '../lib/api'; +import MyPaymentsPage from './MyPaymentsPage'; + +const get = api.get as ReturnType; + +const ROWS = [ + { patient_uuid: 'p1', patient_name: 'دنیا خلیلی', national_code: '1744023654', + invoice_count: 2, paid_rials: 2350000, remaining_rials: 500000, status: 'unsettled' }, + { patient_uuid: 'p2', patient_name: 'علی بدیعی', national_code: '2200112233', + invoice_count: 1, paid_rials: 2000000, remaining_rials: 0, status: 'paid' }, +]; + +beforeEach(() => { + navigate.mockReset(); + get.mockReset(); + get.mockResolvedValue({ success: true, data: ROWS, meta: { totalRecords: 2, totalPages: 1, currentPage: 1 } }); +}); + +describe('MyPaymentsPage (لیست پرداخت‌ها)', () => { + it('renders patient payment rows with derived status labels', async () => { + renderWithProviders(, { route: '/admin/my-payments' }); + expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument(); + expect(screen.getByText('علی بدیعی')).toBeInTheDocument(); + // status labels appear both as a filter