feat(invoice): implement recorder identity resolution for payments and update related tests

This commit is contained in:
hamed
2026-08-04 11:19:07 +03:30
parent 2c7b86d917
commit 810e9351a9
7 changed files with 321 additions and 13 deletions
@@ -8,6 +8,7 @@ vi.mock('../lib/api', () => ({
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import InvoiceSummaryModal from './InvoiceSummaryModal';
const get = api.get as ReturnType<typeof vi.fn>;
@@ -121,6 +122,61 @@ describe('InvoiceSummaryModal', () => {
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
});
/**
* ستون «ثبت‌کننده»: نامِ حل‌شدهٔ سرور، و لینک فقط وقتی بیننده صفحهٔ آن پروفایل را
* می‌تواند باز کند. عکسِ لحظهٔ ثبت (`created_by_name`) فقط fallback است.
*/
describe('ثبت‌کنندهٔ پرداخت', () => {
const withRecorder = (recorder: object | null) => ({
...baseInvoice,
session: {
...fullSession,
payments: [{
uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000,
created_by_name: '09120000000', created_by: recorder,
}],
},
});
it('پزشکِ ثبت‌کننده به پروفایل پزشک لینک می‌شود', async () => {
useAuthStore.setState({ primaryRole: 'clinic' } as any);
mockInvoice(withRecorder({ user_uuid: 'u1', name: 'دکتر رضایی', role: 'doctor', doctor_uuid: 'doc-9' }));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
const link = await screen.findByRole('link', { name: 'دکتر رضایی' });
expect(link).toHaveAttribute('href', '/admin/doctors/doc-9');
// نامِ زنده جای شمارهٔ ذخیره‌شده می‌نشیند.
expect(screen.queryByText('09120000000')).toBeNull();
});
it('منشیِ ثبت‌کننده برای کلینیک به فهرست منشی‌های خودش لینک می‌شود', async () => {
useAuthStore.setState({ primaryRole: 'clinic' } as any);
mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null }));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
const link = await screen.findByRole('link', { name: 'منشی مدیسا' });
expect(link).toHaveAttribute('href', '/admin/my-secretaries');
});
it('بیننده‌ای که آن صفحه را ندارد، فقط نام می‌بیند نه لینک', async () => {
useAuthStore.setState({ primaryRole: 'secretary' } as any);
mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null }));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
expect(await screen.findByText('منشی مدیسا')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'منشی مدیسا' })).toBeNull();
});
it('بدون created_by (کاربر حذف‌شده): همان عکسِ ذخیره‌شده، بدون لینک', async () => {
useAuthStore.setState({ primaryRole: 'clinic' } as any);
mockInvoice(withRecorder(null));
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
expect(await screen.findByText('09120000000')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: '09120000000' })).toBeNull();
});
});
it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => {
mockInvoice(baseInvoice);
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
@@ -1,12 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import Modal from './ui/Modal';
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
import { METHOD_LABELS } from './session/PaymentStep';
interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }
interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null }
/** ثبت‌کنندهٔ پرداخت — نامش زنده از خودِ کاربر حل می‌شود، نه از عکسِ لحظهٔ ثبت. */
interface PaymentRecorder {
user_uuid: string;
name: string | null;
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user';
doctor_uuid: string | null;
}
interface SessionPayment {
uuid: string; method: string; amount_rials: number; paid_at: number;
created_by_name: string | null;
created_by?: PaymentRecorder | null;
}
interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number }
interface SessionData {
session_at: number | null; paid_at: number | null;
@@ -60,8 +73,47 @@ function SectionTable({ title, cols, rows }: { title: string; cols: string[]; ro
);
}
/**
* پروفایلِ ثبت‌کننده در پنلِ همین بیننده — یا `null` وقتی صفحه‌ای برایش وجود ندارد.
*
* فقط پزشک صفحهٔ پروفایلِ مستقل دارد؛ منشی و پرسنل صفحهٔ فهرستِ مدیریتشان را دارند
* و ادمین فهرستِ خودش را. مسیری که نقشِ بیننده اجازه‌اش را ندارد لینک نمی‌شود، وگرنه
* کلیک به داشبورد پرت می‌کرد.
*/
function recorderProfilePath(recorder: PaymentRecorder, viewerRole: string | null): string | null {
if (recorder.role === 'doctor' && recorder.doctor_uuid) {
return ['admin', 'doctor', 'clinic', 'representation'].includes(viewerRole ?? '')
? `/admin/doctors/${recorder.doctor_uuid}`
: null;
}
if (recorder.role === 'secretary') {
if (viewerRole === 'admin') return '/admin/secretaries';
return viewerRole === 'clinic' || viewerRole === 'doctor' ? '/admin/my-secretaries' : null;
}
if (recorder.role === 'staff') {
return ['clinic', 'doctor', 'secretary'].includes(viewerRole ?? '') ? '/admin/staff' : null;
}
if (viewerRole === 'admin') return `/admin/users/${recorder.user_uuid}`;
return null;
}
/** سلولِ «ثبت‌کننده»: نام، و اگر پروفایلی در دسترسِ بیننده باشد، لینکش. */
function RecorderCell({ payment, viewerRole }: { payment: SessionPayment; viewerRole: string | null }) {
const recorder = payment.created_by ?? null;
const name = recorder?.name ?? payment.created_by_name;
if (!name) return <>-</>;
const path = recorder ? recorderProfilePath(recorder, viewerRole) : null;
return path
? <Link to={path} style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{name}</Link>
: <>{name}</>;
}
/** خلاصه فاکتور — invoice summary, ported pixel-for-pixel from tauri InvoiceSummary. */
export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceUuid: string | null; onClose: () => void }) {
const viewerRole = useAuthStore(s => s.primaryRole);
const { data, isLoading } = useQuery<ApiResponse<any>>({
queryKey: ['invoice', invoiceUuid],
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
@@ -156,7 +208,7 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
METHOD_LABELS[p.method] ?? p.method,
formatRial(p.amount_rials),
p.paid_at ? formatDateTime(p.paid_at) : '-',
p.created_by_name ?? '-',
<RecorderCell payment={p} viewerRole={viewerRole} />,
]),
['', <span style={{ fontWeight: 700 }}>مجموع پرداختیها</span>, <span style={{ fontWeight: 700 }}>{formatRial(session.paid_total_rials)}</span>, '', ''],
]