From 069189863cf9e02af069a26cdf2fc79773be4bf4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 11:40:51 +0330 Subject: [PATCH] =?UTF-8?q?feat:=20port=20=D9=BE=D8=B1=D8=AF=D8=A7=D8=AE?= =?UTF-8?q?=D8=AA=E2=80=8C=D9=87=D8=A7=20(payments)=20tab=20from=20tauri?= =?UTF-8?q?=20to=20patient=20detail=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../SessionPaymentAccordion.test.tsx | 43 +++++++ .../components/SessionPaymentAccordion.tsx | 106 ++++++++++++++++++ .../components/icons/FilesServiceIcons.tsx | 10 ++ assets/admin/pages/PatientDetailPage.test.tsx | 10 +- assets/admin/pages/PatientDetailPage.tsx | 67 +++++------ 5 files changed, 196 insertions(+), 40 deletions(-) create mode 100644 assets/admin/components/SessionPaymentAccordion.test.tsx create mode 100644 assets/admin/components/SessionPaymentAccordion.tsx diff --git a/assets/admin/components/SessionPaymentAccordion.test.tsx b/assets/admin/components/SessionPaymentAccordion.test.tsx new file mode 100644 index 00000000..bc9aca00 --- /dev/null +++ b/assets/admin/components/SessionPaymentAccordion.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SessionPaymentAccordion, { type SessionPaymentData } from './SessionPaymentAccordion'; + +const paid: SessionPaymentData = { + uuid: 's1', services: [{ service_name: 'روکش' }], final_price_rials: 2500000, + doctor_name: 'دکتر فتحی', payment_method: 'cash', is_paid: true, + created_at: 1700000000, updated_at: 1700003600, +}; +const unpaid: SessionPaymentData = { + uuid: 's2', services: [{ service_name: 'اسکیلینگ' }], final_price_rials: 1800000, + doctor_name: 'دکتر راد', payment_method: 'pending', is_paid: false, created_at: 1700000000, +}; + +describe('SessionPaymentAccordion', () => { + it('shows the settled badge + settlement line (method/personnel) when expanded', () => { + render( {}} />); + expect(screen.getByText('روکش')).toBeInTheDocument(); + expect(screen.getByText('پرداخت شده')).toBeInTheDocument(); + expect(screen.getByText('نحوه پرداخت:')).toBeInTheDocument(); + expect(screen.getByText('نقدی')).toBeInTheDocument(); // cash → نقدی + expect(screen.getByText('دکتر فتحی')).toBeInTheDocument(); // personnel = doctor + }); + + it('shows the unsettled badge + empty message for an unpaid session', () => { + render( {}} />); + expect(screen.getByText('تسویه نشده')).toBeInTheDocument(); + expect(screen.getByText('هیچ پرداختی ثبت نشده است.')).toBeInTheDocument(); + }); + + it('hides the details when collapsed', () => { + render( {}} />); + expect(screen.getByText('پرداخت شده')).toBeInTheDocument(); // header still visible + expect(screen.queryByText('نحوه پرداخت:')).not.toBeInTheDocument(); + }); + + it('calls onToggle when the summary is clicked', () => { + const onToggle = vi.fn(); + render(); + screen.getByRole('button').click(); + expect(onToggle).toHaveBeenCalledOnce(); + }); +}); diff --git a/assets/admin/components/SessionPaymentAccordion.tsx b/assets/admin/components/SessionPaymentAccordion.tsx new file mode 100644 index 00000000..24c7ab07 --- /dev/null +++ b/assets/admin/components/SessionPaymentAccordion.tsx @@ -0,0 +1,106 @@ +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { formatDate, formatRial } from '../lib/utils'; +import { FilesServicePaymentsCheck } from './icons/FilesServiceIcons'; + +export interface SessionPaymentData { + uuid: string; + services?: Array<{ service_name?: string; name?: string }>; + visit_price_rials?: number; + doctor_name?: string | null; + final_price_rials?: number; + payment_method?: string; + is_paid?: boolean; + created_at?: number; + updated_at?: number; +} + +const PAYMENT_LABELS: Record = { + cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار', +}; + +/** vertical hairline divider between meta columns (tauri MUI vertical Divider). */ +function VDivider({ h = 24 }: { h?: number }) { + return ; +} + +function Meta({ label, value }: { label: string; value: string }) { + return ( + + {label}: + {value} + + ); +} + +/** + * A patient «پرداخت‌ها» accordion — one settled/unsettled مراجعه (session) — + * ported from tauri files/tabs/PaymentsSection. Header shows the service, date, + * final price and settlement badge; the body lists the session's settlement + * (real model = one payment_method per session), or the empty message. + */ +export default function SessionPaymentAccordion({ session, expanded, onToggle }: { + session: SessionPaymentData; + expanded: boolean; + onToggle: () => void; +}) { + const names = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[]; + if ((session.visit_price_rials ?? 0) > 0) names.unshift('ویزیت'); + const name = names.length ? names.join(' - ') : 'ویزیت'; + const paid = !!session.is_paid; + const finalPrice = formatRial(session.final_price_rials ?? 0); + + return ( +
+ {/* summary */} + + + {/* details */} + {expanded && ( +
+
+ + پرداختی‌ها +
+ +
+ {paid ? ( +
+ + + + + + + + +
+ ) : ( +
هیچ پرداختی ثبت نشده است.
+ )} +
+
+ )} +
+ ); +} diff --git a/assets/admin/components/icons/FilesServiceIcons.tsx b/assets/admin/components/icons/FilesServiceIcons.tsx index 14ca16fa..595b835d 100644 --- a/assets/admin/components/icons/FilesServiceIcons.tsx +++ b/assets/admin/components/icons/FilesServiceIcons.tsx @@ -193,6 +193,16 @@ export function TabBody({ color = '#616161', style }: IconProps) { ); } +/** Payment check icon (tauri FilesServicePaymentsCheckIcon) — rounded-square tick. */ +export function FilesServicePaymentsCheck({ color = '#2F2F2F', size = 20, style }: IconProps & { size?: number }) { + return ( + + + + + ); +} + /* ── Turn card info-row icons (tauri CalendarD / ClockP / UserD / status) ───── */ export function CalendarD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) { diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index e27cb88c..517c262d 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -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 () => { diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 47bbf6a5..2c26e90c 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -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) => }, ]; -const PAYMENT_STATUS: Record = { - pending: 'در انتظار', success: 'موفق', failed: 'ناموفق', canceled: 'لغو شده', refunded: 'بازگشت', -}; - function Placeholder({ label }: { label: string }) { return (
@@ -134,12 +131,6 @@ export default function PatientDetailPage() { queryFn: () => api.get(`/api/v1/patient/${uuid}/appointments`), enabled: !!uuid, }); - const paymentsQ = useQuery>({ - 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' ? ( ) : tab === 'payments' ? ( - ({ 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 })} /> + ) : tab === 'wallet' ? ( ) : 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; isLoading: boolean } }) { + const items = (q.data?.data ?? []) as SessionPaymentData[]; + // undefined = default (first panel open, matching tauri); null = user closed all. + const [expanded, setExpanded] = useState(undefined); + const openUuid = expanded === undefined ? (items[0]?.uuid ?? null) : expanded; + + if (q.isLoading) return
در حال بارگذاری...
; + if (items.length === 0) return
پرداختی ثبت نشده است
; + + return ( +
+ {items.map((s) => ( + setExpanded(openUuid === s.uuid ? null : s.uuid)} + /> + ))} +
+ ); +} + 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; isLoading: boolean }; - emptyLabel: string; - row: (item: any) => { title: string; meta?: string; badge?: string }; -}) { - if (q.isLoading) return
در حال بارگذاری...
; - const items = q.data?.data ?? []; - if (items.length === 0) return
{emptyLabel}
; - return ( -
- {items.map((item, i) => { - const r = row(item); - return ( -
-
-
{r.title}
- {r.meta &&
{r.meta}
} -
- {r.badge && {r.badge}} -
- ); - })} -
- ); -}