diff --git a/assets/admin/components/InvoiceSummaryModal.test.tsx b/assets/admin/components/InvoiceSummaryModal.test.tsx
index a9d1e71b..33bbaa79 100644
--- a/assets/admin/components/InvoiceSummaryModal.test.tsx
+++ b/assets/admin/components/InvoiceSummaryModal.test.tsx
@@ -22,6 +22,8 @@ const fullSession = {
session_at: 1700000000, paid_at: 1700100000,
services_total_rials: 2_400_000, consumables_total_rials: 40_000,
discount_rials: 200_000, final_price_rials: 2_240_000, paid_total_rials: 1_500_000,
+ // API واقعی همیشه این را میفرستد: max(0, final − discount − paid)
+ remaining_rials: 540_000,
payments: [
{ uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' },
],
@@ -59,9 +61,10 @@ describe('InvoiceSummaryModal', () => {
expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument();
// وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰):
- // پرداختشده ۱۵۰٬۰۰۰ (هم در جدول پرداختیها هم وضعیت) و باقیمانده ۷۴٬۰۰۰
+ // پرداختشده ۱۵۰٬۰۰۰ (هم در جدول پرداختیها هم وضعیت) و باقیمانده ۵۴٬۰۰۰
+ // (۲۲۴۰۰۰۰ − ۲۰۰۰۰۰ تخفیف − ۱۵۰۰۰۰۰ پرداختی = ۵۴۰۰۰۰ ریال؛ final_price پیش از تخفیف است)
expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2);
- expect(screen.getByText(/۷۴٬۰۰۰/)).toBeInTheDocument();
+ expect(screen.getByText(/۵۴٬۰۰۰/)).toBeInTheDocument();
});
it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ میشود', async () => {
diff --git a/assets/admin/components/SessionServiceCard.tsx b/assets/admin/components/SessionServiceCard.tsx
index 4978c51a..8fd9e9ba 100644
--- a/assets/admin/components/SessionServiceCard.tsx
+++ b/assets/admin/components/SessionServiceCard.tsx
@@ -14,7 +14,8 @@ export interface SessionPaymentEntry {
export interface SessionCardData {
uuid: string;
services?: Array<{ service_item_uuid?: string; service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>;
- consumables?: Array<{ inventory_item_uuid?: string; item_name?: string; price_rials?: number; quantity?: number }>;
+ // مطابق SessionConsumable::toArray در بکاند
+ consumables?: Array<{ uuid?: string; inventory_item_uuid?: string; item_name?: string; unit?: string; price_rials?: number; quantity?: number; line_total_rials?: number }>;
visit_price_rials?: number;
services_total_rials?: number;
session_at?: number | null;
diff --git a/assets/admin/components/appointments/DoctorTabs.tsx b/assets/admin/components/appointments/DoctorTabs.tsx
index bee9671a..b084702d 100644
--- a/assets/admin/components/appointments/DoctorTabs.tsx
+++ b/assets/admin/components/appointments/DoctorTabs.tsx
@@ -33,6 +33,8 @@ export default function DoctorTabs({
position: 'relative', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 15, fontWeight: 500, padding: '8px 2px 12px',
color: active ? 'var(--primary)' : 'var(--text-2)', whiteSpace: 'nowrap',
+ // برچسبهای کوتاه («همه») بدون این، عرضشان زیر ۴۴px میماند (WCAG 2.5.5)
+ minWidth: 44,
}}
>
{d.name}
diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx
index 2c3c42fb..53ddcc3f 100644
--- a/assets/admin/components/layout/SettingsLayout.test.tsx
+++ b/assets/admin/components/layout/SettingsLayout.test.tsx
@@ -58,7 +58,9 @@ describe('SettingsLayout', () => {
// SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU
expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic-doctors');
expect(menuForRole('clinic').map((i) => i.key)).not.toContain('doctor');
- expect(menuForRole('clinic').map((i) => i.key)).not.toContain('appointment');
+ // کلینیک ورودی تنظیمات نوبتدهیِ مخصوص خودش را دارد
+ expect(menuForRole('clinic').find((i) => i.key === 'appointment')?.to)
+ .toBe('/admin/settings/appointment-settings');
// a plain doctor must not get the clinic-doctors management tab
expect(menuForRole('doctor').map((i) => i.key)).not.toContain('clinic-doctors');
});
diff --git a/assets/admin/components/session/DetailsStep.tsx b/assets/admin/components/session/DetailsStep.tsx
index f95a14d4..737169f3 100644
--- a/assets/admin/components/session/DetailsStep.tsx
+++ b/assets/admin/components/session/DetailsStep.tsx
@@ -41,7 +41,9 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
// زمان مراجعه: زمان واقعی نوبت؛ در نبود آن، زمان ثبت پرونده.
const visitAt = session.session_at ?? session.created_at ?? null;
- // آیتمهای هزینهی تفکیکشده: ویزیت + هر سرویس، با جمع کل.
+ // آیتمهای هزینهی تفکیکشده: ویزیت + هر سرویس + کالای مصرفی.
+ // کالای مصرفی هم باید بیاید چون سرور آن را در سهم بیمار میآورد
+ // (PatientService::…$consumablesTotal)؛ نبودش ریز هزینهها را با جمع ناسازگار میکرد.
const costItems: Array<{ label: string; rials: number }> = [];
if (visitPrice > 0) costItems.push({ label: 'ویزیت', rials: visitPrice });
(session.services ?? []).forEach((s) => {
@@ -50,6 +52,19 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
rials: s.line_total_rials ?? (s.price_rials ?? 0) * (s.quantity ?? 1),
});
});
+ (session.consumables ?? []).forEach((c) => {
+ costItems.push({
+ label: c.item_name ?? 'کالای مصرفی',
+ rials: c.line_total_rials ?? 0,
+ });
+ });
+
+ // خطوط بالا ناخالصاند (پیش از بیمه) ولی finalPrice سهمِ بیمار پس از بیمه است.
+ // پس وقتی بیمه هست، جمعِ خطوط را ناخالص نشان بده و سهم بیمه را جدا کم کن.
+ const grossTotal = session.gross_total_rials ?? finalPrice;
+ const baseInsurance = session.base_insurance_rials ?? 0;
+ const suppInsurance = session.supplementary_insurance_rials ?? 0;
+ const hasInsurance = baseInsurance > 0 || suppInsurance > 0;
return (
<>
@@ -78,8 +93,28 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
{formatRial(it.rials)}
))}
+ {hasInsurance && (
+ <>
+
+ جمع کل خدمات
+ {formatRial(grossTotal)}
+
+ {baseInsurance > 0 && (
+
+ سهم بیمه پایه
+ {formatRial(baseInsurance)}
+
+ )}
+ {suppInsurance > 0 && (
+
+ سهم بیمه تکمیلی
+ {formatRial(suppInsurance)}
+
+ )}
+ >
+ )}
- جمع کل
+ {hasInsurance ? 'سهم بیمار' : 'جمع کل'}
{formatRial(finalPrice)}
diff --git a/assets/admin/components/session/PaymentStep.tsx b/assets/admin/components/session/PaymentStep.tsx
index 715f1deb..64f1bc9a 100644
--- a/assets/admin/components/session/PaymentStep.tsx
+++ b/assets/admin/components/session/PaymentStep.tsx
@@ -124,9 +124,10 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
const baseInsurance = session.base_insurance_rials ?? 0;
const suppInsurance = session.supplementary_insurance_rials ?? 0;
const hasInsurance = baseInsurance > 0 || suppInsurance > 0;
- const payable = session.remaining_rials !== undefined
- ? session.remaining_rials + (session.paid_total_rials ?? 0)
- : Math.max(0, finalPrice - discountRials);
+ // همان فرمول سرور (PatientSession::getPayableRials). بازسازی از روی
+ // `remaining + paid` غلط بود: سرور remaining را در صفر کلمپ میکند، پس در
+ // بیشپرداخت مبلغ نهایی تا اندازهی پرداختی بالا میرفت و اضافهپرداخت پنهان میشد.
+ const payable = Math.max(0, finalPrice - discountRials);
const debt = session.remaining_rials ?? session.patient_debt_rials ?? 0;
/**
diff --git a/assets/admin/components/ui/PersianDateInput.test.tsx b/assets/admin/components/ui/PersianDateInput.test.tsx
index f9556533..6f348c0e 100644
--- a/assets/admin/components/ui/PersianDateInput.test.tsx
+++ b/assets/admin/components/ui/PersianDateInput.test.tsx
@@ -24,7 +24,8 @@ describe('PersianDateInput', () => {
const onChange = vi.fn();
const { container } = render();
fireEvent.click((container.firstChild as HTMLElement).firstChild as Element);
- const target = Array.from(container.querySelectorAll('button')).find(
+ // تقویم با createPortal به document.body میرود، پس بیرون از container است
+ const target = Array.from(document.body.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === '۱۵',
);
expect(target).toBeTruthy();
diff --git a/assets/admin/components/ui/SearchableSelect.test.tsx b/assets/admin/components/ui/SearchableSelect.test.tsx
index 8b6b27e4..58338e7a 100644
--- a/assets/admin/components/ui/SearchableSelect.test.tsx
+++ b/assets/admin/components/ui/SearchableSelect.test.tsx
@@ -31,3 +31,25 @@ describe('SearchableSelect — تطبیق مقدار', () => {
expect(screen.queryByText('تامین اجتماعی')).not.toBeInTheDocument();
});
});
+
+describe('SearchableSelect — نام دسترسپذیر', () => {
+ it('در نبود ariaLabel، از placeholder بهعنوان نام استفاده میکند', () => {
+ render( {}} placeholder="نوع بیمه" />);
+ expect(screen.getByRole('combobox', { name: 'نوع بیمه' })).toBeInTheDocument();
+ });
+
+ it('ariaLabel بر placeholder اولویت دارد', () => {
+ render( {}} placeholder="انتخاب کنید..." ariaLabel="بیمه پایه" />);
+ expect(screen.getByRole('combobox', { name: 'بیمه پایه' })).toBeInTheDocument();
+ });
+
+ it('ariaLabelledBy به label قابلمشاهده وصل میشود', () => {
+ render(
+ <>
+ شهر
+ {}} ariaLabelledBy="lbl-city" />
+ >,
+ );
+ expect(screen.getByRole('combobox', { name: 'شهر' })).toBeInTheDocument();
+ });
+});
diff --git a/assets/admin/components/ui/SearchableSelect.tsx b/assets/admin/components/ui/SearchableSelect.tsx
index 0b77dd41..6d4cb1cc 100644
--- a/assets/admin/components/ui/SearchableSelect.tsx
+++ b/assets/admin/components/ui/SearchableSelect.tsx
@@ -18,6 +18,14 @@ interface Props {
noOptionsMessage?: string;
inputId?: string;
height?: number;
+ /**
+ * نام دسترسپذیر فیلد. react-select ورودی داخلی خودش را بدون label رندر میکند و
+ * placeholder را هم بهصورت div میگذارد نه attribute، پس بدون این، فیلد برای
+ * screen reader بینام میماند. پیشفرض روی placeholder میافتد.
+ */
+ ariaLabel?: string;
+ /** اگر label قابلمشاهدهای وجود دارد، id آن را بده (بر ariaLabel اولویت دارد). */
+ ariaLabelledBy?: string;
}
export default function SearchableSelect({
@@ -31,6 +39,8 @@ export default function SearchableSelect({
noOptionsMessage = 'موردی یافت نشد',
inputId,
height = 42,
+ ariaLabel,
+ ariaLabelledBy,
}: Props) {
const darkMode = useUiStore((s) => s.darkMode);
@@ -118,6 +128,8 @@ export default function SearchableSelect({
noOptionsMessage={() => noOptionsMessage}
loadingMessage={() => 'در حال بارگذاری...'}
inputId={inputId}
+ aria-labelledby={ariaLabelledBy}
+ aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? placeholder)}
/>
);
}
diff --git a/assets/admin/lib/api.test.ts b/assets/admin/lib/api.test.ts
index e8d6df7c..acdf4f01 100644
--- a/assets/admin/lib/api.test.ts
+++ b/assets/admin/lib/api.test.ts
@@ -14,7 +14,8 @@ import { api, ApiError } from '@/lib/api';
const replaceMock = vi.fn();
function jsonRes(body: unknown, ok = true, status = 200): Response {
- return { ok, status, json: () => Promise.resolve(body) } as unknown as Response;
+ // headers لازم است: api.ts پیش از خواندن بدنه، Content-Length را برای پاسخ خالی چک میکند
+ return { ok, status, headers: new Headers(), json: () => Promise.resolve(body) } as unknown as Response;
}
function setToken(token: string) {
diff --git a/assets/admin/pages/AppointmentDetailPage.test.tsx b/assets/admin/pages/AppointmentDetailPage.test.tsx
index 23ac40aa..66f72915 100644
--- a/assets/admin/pages/AppointmentDetailPage.test.tsx
+++ b/assets/admin/pages/AppointmentDetailPage.test.tsx
@@ -17,13 +17,20 @@ const slotStart = Math.floor(new Date('2024-06-01T12:00:00').getTime() / 1000);
beforeEach(() => {
get.mockReset();
- get.mockResolvedValue({
- success: true,
- data: {
- uuid: 'ap1', patient_name: 'ساغر', patient_mobile: '09120000000',
- slot_start: slotStart, slot_end: slotStart + 1800, status: 'confirmed', version: 1, created_at: slotStart,
- },
- });
+ // mock باید به URL حساس باشد: /events یک لیست برمیگرداند نه آبجکت نوبت
+ get.mockImplementation((url: string) =>
+ Promise.resolve(
+ String(url).endsWith('/events')
+ ? { success: true, data: [] }
+ : {
+ success: true,
+ data: {
+ uuid: 'ap1', patient_name: 'ساغر', patient_mobile: '09120000000',
+ slot_start: slotStart, slot_end: slotStart + 1800, status: 'confirmed', version: 1, created_at: slotStart,
+ },
+ },
+ ),
+ );
});
describe('AppointmentDetailPage — بازگشت به همان روز', () => {
diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx
index 52c65c5a..5e60a2ff 100644
--- a/assets/admin/pages/CategoriesPage.tsx
+++ b/assets/admin/pages/CategoriesPage.tsx
@@ -100,7 +100,7 @@ function LogoUploadField({ value, onChange, uploadUrl }: {
JPG، PNG یا WebP
@@ -212,7 +212,7 @@ function TabActions({ label, onClick, exportUrl, exportFile, bundle, entityLabel
return (
-
+