diff --git a/assets/admin/components/AppointmentInvoiceCard.tsx b/assets/admin/components/AppointmentInvoiceCard.tsx index d0be6cdb..6f399f0f 100644 --- a/assets/admin/components/AppointmentInvoiceCard.tsx +++ b/assets/admin/components/AppointmentInvoiceCard.tsx @@ -69,7 +69,8 @@ export default function AppointmentInvoiceCard({ appointmentUuid }: Props) { ))} - {invoice.breakdown.discounts.map((line, index) => ( + {/* فاکتور قدیمی ممکن است ریز تخفیف نداشته باشد؛ نبودنش نباید کل صفحه را ببندد. */} + {(invoice.breakdown?.discounts ?? []).map((line, index) => (
({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { api } from '../lib/api'; +import AppointmentSegmentsCard from './AppointmentSegmentsCard'; + +const get = api.get as ReturnType; + +const segment = (over: Record) => ({ + sequence: 1, + name: 'ویزیت', + starts_at: 1_800_000_000, + ends_at: 1_800_001_200, + duration_minutes: 20, + patient_present: true, + ...over, +}); + +describe('AppointmentSegmentsCard', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sums the recorded segments rather than the service duration', async () => { + get.mockResolvedValue({ + data: [ + segment({ sequence: 1, duration_minutes: 20 }), + segment({ sequence: 2, name: 'انتظار', duration_minutes: 40, patient_present: false }), + ], + }); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('60 دقیقه')).toBeInTheDocument()); + }); + + /** ⭐ مدتی که بیمار روی صندلی نیست باید دیده شود، وگرنه «۹۰ دقیقه» گمراه‌کننده است. */ + it('marks the segments the patient is not present for', async () => { + get.mockResolvedValue({ + data: [segment({ sequence: 1, name: 'انتظار', patient_present: false })], + }); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('بدون حضور بیمار')).toBeInTheDocument()); + }); + + /** نوبت اسلاتی بخشی ندارد؛ کارتِ خالی یعنی «چیزی خراب است». */ + it('renders nothing for an appointment with no segments', async () => { + get.mockResolvedValue({ data: [] }); + + const { container } = renderWithProviders(); + + await waitFor(() => expect(get).toHaveBeenCalled()); + expect(container.textContent).toBe(''); + }); +}); diff --git a/assets/admin/components/AppointmentSegmentsCard.tsx b/assets/admin/components/AppointmentSegmentsCard.tsx new file mode 100644 index 00000000..e2dacf02 --- /dev/null +++ b/assets/admin/components/AppointmentSegmentsCard.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { useAppointmentSegments } from '../hooks/useResourceBooking'; + +const timeOf = (ts: number) => + new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }); + +/** + * بخش‌های ثبت‌شدهٔ نوبت — همان چیزی که لحظهٔ رزرو تثبیت شد، نه الگوی امروزِ خدمت. + * + * بخشی که بیمار در آن حاضر نیست کم‌رنگ می‌آید: اپراتوری که «۹۰ دقیقه» را روی نوبت + * می‌بیند باید بفهمد بیمار همهٔ آن مدت روی صندلی نیست. + * + * نوبت اسلاتی بخشی ندارد و کارت اصلاً رندر نمی‌شود — جدول خالی یعنی چیزی خراب است. + */ +export default function AppointmentSegmentsCard({ appointmentUuid }: { appointmentUuid: string }) { + const { segments, loading } = useAppointmentSegments(appointmentUuid); + + if (loading || segments.length === 0) return null; + + const total = segments.reduce((sum, s) => sum + s.duration_minutes, 0); + + return ( +
+
+

بخش‌های نوبت

+ {total} دقیقه +
+ +
+ {segments.map((s) => ( +
+ ))} +
+ +
    + {segments.map((s) => ( +
  1. + {s.sequence} + {s.name} + + {timeOf(s.starts_at)} – {timeOf(s.ends_at)} + + {!s.patient_present && ( + + بدون حضور بیمار + + )} +
  2. + ))} +
+
+ ); +} diff --git a/assets/admin/components/CancelAppointmentDialog.test.tsx b/assets/admin/components/CancelAppointmentDialog.test.tsx new file mode 100644 index 00000000..3319c473 --- /dev/null +++ b/assets/admin/components/CancelAppointmentDialog.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { api } from '../lib/api'; +import CancelAppointmentDialog from './CancelAppointmentDialog'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; + +const preview = (over: Record = {}) => ({ + data: { + penalty_rials: 250_000, + deposit_refundable: true, + credit_refundable: true, + within_free_window: false, + notes: [], + paid_rials: 1_000_000, + ...over, + }, +}); + +describe('CancelAppointmentDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + post.mockResolvedValue({ data: { waitlist_notified: 0 } }); + }); + + /** ⭐ عددی که اپراتور می‌بیند باید همان باشد که کسر می‌شود. */ + it('shows the penalty from the server before anything is cancelled', async () => { + get.mockResolvedValue(preview()); + + renderWithProviders( + {}} />, + ); + + // مبلغ‌ها به تومان نمایش داده می‌شوند: ۲۵۰٬۰۰۰ ریال ⇒ ۲۵٬۰۰۰ تومان. + await waitFor(() => expect(screen.getByText(/۲۵٬۰۰۰ تومان/)).toBeInTheDocument()); + expect(post).not.toHaveBeenCalled(); + }); + + it('says "بدون جریمه" inside the free window instead of showing a zero', async () => { + get.mockResolvedValue(preview({ penalty_rials: 0, within_free_window: true })); + + renderWithProviders( + {}} />, + ); + + await waitFor(() => expect(screen.getByText('بدون جریمه')).toBeInTheDocument()); + expect(screen.getByText('در بازهٔ لغو رایگان است.')).toBeInTheDocument(); + }); + + it('sends the typed reason along with the cancellation', async () => { + get.mockResolvedValue(preview()); + const user = userEvent.setup(); + + renderWithProviders( + {}} />, + ); + + await waitFor(() => expect(screen.getByText(/مبلغ پرداخت‌شده/)).toBeInTheDocument()); + + await user.type(screen.getByLabelText(/دلیل لغو/), 'بیمار تماس گرفت'); + await user.click(screen.getByRole('button', { name: 'لغو نوبت' })); + + await waitFor(() => + expect(post).toHaveBeenCalledWith('/api/v1/appointment/a-1/cancel', { + by: 'doctor', + reason: 'بیمار تماس گرفت', + }), + ); + }); + + /** پیش‌نمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید که نیامده. */ + it('still allows cancelling when the preview fails, and says so', async () => { + get.mockRejectedValue(new Error('down')); + + renderWithProviders( + {}} />, + ); + + await waitFor(() => + expect(screen.getByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument(), + ); + + expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled(); + }); +}); diff --git a/assets/admin/components/CancelAppointmentDialog.tsx b/assets/admin/components/CancelAppointmentDialog.tsx new file mode 100644 index 00000000..085a72f4 --- /dev/null +++ b/assets/admin/components/CancelAppointmentDialog.tsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import ConfirmDialog from './ui/ConfirmDialog'; +import { useCancellationPreview, useCancelAppointment } from '../hooks/useCancellation'; +import { formatRial } from '../lib/utils'; + +interface Props { + open: boolean; + appointmentUuid: string; + /** چه کسی لغو می‌کند — جریمه برای لغو کلینیک همیشه صفر است. */ + by?: 'user' | 'doctor'; + onClose: () => void; + onCancelled?: () => void; +} + +/** + * لغو نوبت با نمایش پیامد مالی **قبل از** تأیید. + * + * پیش‌نمایش از همان محاسبه‌ای می‌آید که خودِ لغو انجام می‌دهد، پس عددی که اپراتور + * می‌بیند همان است که کسر می‌شود. دکمهٔ لغوِ بی‌پیش‌نمایش یعنی اپراتور جریمهٔ بیمار را + * بعد از وقوعش کشف می‌کند. + */ +export default function CancelAppointmentDialog({ + open, + appointmentUuid, + by = 'doctor', + onClose, + onCancelled, +}: Props) { + const [reason, setReason] = useState(''); + const { preview, loading } = useCancellationPreview(open ? appointmentUuid : undefined, by); + const cancel = useCancelAppointment(); + + const close = () => { + setReason(''); + onClose(); + }; + + return ( + + cancel.mutate( + { uuid: appointmentUuid, by, reason }, + { + onSuccess: () => { + close(); + onCancelled?.(); + }, + }, + ) + } + onCancel={close} + > +
+ {loading ? ( + در حال محاسبهٔ پیامد لغو… + ) : !preview ? ( + + پیامد مالی لغو در دسترس نیست؛ لغو انجام می‌شود ولی مبلغ را دستی بررسی کنید. + + ) : ( + <> +
+ جریمهٔ لغو + 0 ? 'var(--danger)' : 'var(--success)' }}> + {preview.penalty_rials > 0 ? formatRial(preview.penalty_rials) : 'بدون جریمه'} + +
+ +
+ مبلغ پرداخت‌شده + {formatRial(preview.paid_rials)} +
+ +
+ بازگشت اعتبار پکیج + {preview.credit_refundable ? 'بله' : 'خیر'} +
+ + {preview.within_free_window && ( +
در بازهٔ لغو رایگان است.
+ )} + + {preview.notes.map((note, i) => ( +
+ {note} +
+ ))} + + )} +
+ +
+ +