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:
@@ -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(<SessionPaymentAccordion session={paid} expanded onToggle={() => {}} />);
|
||||
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(<SessionPaymentAccordion session={unpaid} expanded onToggle={() => {}} />);
|
||||
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
|
||||
expect(screen.getByText('هیچ پرداختی ثبت نشده است.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the details when collapsed', () => {
|
||||
render(<SessionPaymentAccordion session={paid} expanded={false} onToggle={() => {}} />);
|
||||
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(<SessionPaymentAccordion session={paid} expanded={false} onToggle={onToggle} />);
|
||||
screen.getByRole('button').click();
|
||||
expect(onToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
|
||||
};
|
||||
|
||||
/** vertical hairline divider between meta columns (tauri MUI vertical Divider). */
|
||||
function VDivider({ h = 24 }: { h?: number }) {
|
||||
return <span className="dark:bg-[#35343D]" style={{ width: 1, height: h, background: '#E5E7EB', flexShrink: 0, margin: '0 16px', alignSelf: 'center' }} />;
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>{label}:</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ color: '#525252', fontWeight: 500 }}>{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="dark:border-[#35343D]" style={{ border: '1px solid #E5E7EB', borderRadius: 8, marginBottom: 16, overflow: 'hidden' }}>
|
||||
{/* summary */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="bg-white dark:bg-[#2B2D3A]"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%', padding: '12px 16px', border: 'none', cursor: 'pointer', textAlign: 'start', flexWrap: 'wrap' }}
|
||||
>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ minWidth: 150, color: '#2f2f2f', fontWeight: 500, fontSize: 14 }}>{name}</span>
|
||||
<VDivider />
|
||||
<Meta label="تاریخ" value={session.created_at ? formatDate(session.created_at) : '—'} />
|
||||
<VDivider />
|
||||
<Meta label="مبلغ نهایی" value={finalPrice} />
|
||||
<span style={{ flex: 1 }} />
|
||||
<VDivider />
|
||||
<span style={{
|
||||
color: '#fff', padding: '3px 16px', borderRadius: 4, fontSize: 12, fontWeight: 600,
|
||||
textAlign: 'center', minWidth: 90, background: paid ? '#10B981' : '#F59E0B',
|
||||
}}>
|
||||
{paid ? 'پرداخت شده' : 'تسویه نشده'}
|
||||
</span>
|
||||
<ChevronDownIcon className="dark:text-[#D7D8ED]" style={{ width: 20, height: 20, color: '#525252', marginInlineStart: 8, transition: 'transform .2s', transform: expanded ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
|
||||
{/* details */}
|
||||
{expanded && (
|
||||
<div className="dark:border-[#35343D] dark:bg-[#222433]" style={{ borderTop: '1px solid #E5E7EB' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px 8px' }}>
|
||||
<FilesServicePaymentsCheck color="#6B7280" size={18} style={{ transform: 'rotate(180deg)' }} />
|
||||
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280', fontWeight: 600, fontSize: 13 }}>پرداختیها</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
{paid ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 0', flexWrap: 'wrap' }}>
|
||||
<FilesServicePaymentsCheck color="#6B7280" size={20} />
|
||||
<Meta label="تاریخ" value={formatDate(session.updated_at ?? session.created_at ?? 0)} />
|
||||
<VDivider h={32} />
|
||||
<Meta label="مبلغ" value={finalPrice} />
|
||||
<VDivider h={32} />
|
||||
<Meta label="نحوه پرداخت" value={PAYMENT_LABELS[session.payment_method ?? ''] ?? session.payment_method ?? '—'} />
|
||||
<VDivider h={32} />
|
||||
<Meta label="پرسنل" value={session.doctor_name || '—'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dark:text-[#A1A1A1]" style={{ padding: 16, textAlign: 'center', color: '#6B7280', fontSize: 13 }}>هیچ پرداختی ثبت نشده است.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
|
||||
<path d="M7.49984 18.3332H12.4998C16.6665 18.3332 18.3332 16.6665 18.3332 12.4998V7.49984C18.3332 3.33317 16.6665 1.6665 12.4998 1.6665H7.49984C3.33317 1.6665 1.6665 3.33317 1.6665 7.49984V12.4998C1.6665 16.6665 3.33317 18.3332 7.49984 18.3332Z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.4585 9.99993L8.81683 12.3583L13.5418 7.6416" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Turn card info-row icons (tauri CalendarD / ClockP / UserD / status) ───── */
|
||||
|
||||
export function CalendarD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user