feat(stepper): implement multi-step wizard for appointment confirmation process
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../../lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import PatientTreatmentTab from './PatientTreatmentTab';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
const CASE_UUID = 'case-1';
|
||||
|
||||
const summary = (over: Record<string, unknown> = {}) => ({
|
||||
uuid: CASE_UUID,
|
||||
status: 'active',
|
||||
total_sessions: 5,
|
||||
completed_sessions: 3,
|
||||
service: { uuid: 'svc-1', name: 'لیزر ناحیه ۱' },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** فهرست دورهها، تقویم دوره، و بقیهٔ GETها. */
|
||||
function mockApi(caseOver: Record<string, unknown> = {}) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/treatment-cases')) {
|
||||
return Promise.resolve({ success: true, data: [summary(caseOver)] });
|
||||
}
|
||||
if (url.includes('/plan')) {
|
||||
return Promise.resolve({ success: true, data: {
|
||||
case: { ...summary(caseOver), patient_national_code: null },
|
||||
resource: null,
|
||||
sessions: [],
|
||||
} });
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
patch.mockReset();
|
||||
patch.mockResolvedValue({ success: true, data: {} });
|
||||
mockApi();
|
||||
});
|
||||
|
||||
/** دورهٔ انتخابشده در URL مینشیند؛ تست هم از همان مسیر شروع میکند. */
|
||||
const render = () => renderWithProviders(
|
||||
<PatientTreatmentTab recordUuid="rec-1" />,
|
||||
{ route: `/admin/patients/p-1?case=${CASE_UUID}` },
|
||||
);
|
||||
|
||||
const plusBtn = () => screen.getByRole('button', { name: 'افزودن یک جلسه' });
|
||||
const minusBtn = () => screen.getByRole('button', { name: 'کم کردن یک جلسه' });
|
||||
|
||||
/**
|
||||
* تعداد جلسات سرِ پروندهٔ بیمار تصمیم گرفته میشود، ولی تا این تغییر فقط از صفحهٔ
|
||||
* «دورههای درمان» و پشت یک مودال قابل تغییر بود.
|
||||
*/
|
||||
describe('PatientTreatmentTab — تعداد جلسات', () => {
|
||||
it('یک جلسه اضافه میکند', async () => {
|
||||
render();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'افزودن یک جلسه' }));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 6 },
|
||||
));
|
||||
});
|
||||
|
||||
it('یک جلسه کم میکند', async () => {
|
||||
render();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 4 },
|
||||
));
|
||||
});
|
||||
|
||||
it('وقتی همهٔ جلسهها انجام شده، کم کردن قفل است', async () => {
|
||||
mockApi({ total_sessions: 4, completed_sessions: 4 });
|
||||
render();
|
||||
|
||||
await waitFor(() => expect(minusBtn()).toBeDisabled());
|
||||
expect(screen.getByText(/همهٔ جلسهها انجام شده/)).toBeInTheDocument();
|
||||
// افزودن همچنان آزاد است.
|
||||
expect(plusBtn()).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('کف دو جلسه رعایت میشود', async () => {
|
||||
mockApi({ total_sessions: 2, completed_sessions: 0 });
|
||||
render();
|
||||
|
||||
await waitFor(() => expect(minusBtn()).toBeDisabled());
|
||||
expect(screen.getByText(/کمتر از ۲ جلسه ممکن نیست/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('سقف شصت جلسه رعایت میشود', async () => {
|
||||
mockApi({ total_sessions: 60, completed_sessions: 0 });
|
||||
render();
|
||||
|
||||
await waitFor(() => expect(plusBtn()).toBeDisabled());
|
||||
expect(minusBtn()).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('خطای سرور را همانطور که آمده نشان میدهد', async () => {
|
||||
// سرور دقیقتر از هر متن ثابتی میگوید چند جلسه قفل است.
|
||||
patch.mockRejectedValue(new Error('۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست'));
|
||||
render();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(
|
||||
'۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست',
|
||||
));
|
||||
});
|
||||
|
||||
it('پیشرفت دوره با نسبت واقعی اعلام میشود', async () => {
|
||||
render();
|
||||
|
||||
const bar = await screen.findByRole('progressbar', { name: /پیشرفت دوره/ });
|
||||
|
||||
expect(bar).toHaveAttribute('aria-valuenow', '3');
|
||||
expect(bar).toHaveAttribute('aria-valuemax', '5');
|
||||
});
|
||||
|
||||
it('دکمهٔ ویرایش دوره برای بقیهٔ فیلدها هست', async () => {
|
||||
render();
|
||||
|
||||
expect(await screen.findByRole('button', { name: /ویرایش دوره/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon,
|
||||
CpuChipIcon, MagnifyingGlassIcon, XMarkIcon,
|
||||
CpuChipIcon, MagnifyingGlassIcon, MinusIcon, PencilSquareIcon, PlusIcon, XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import StatusBadge from '../ui/StatusBadge';
|
||||
import TreatmentCaseEditModal from '../TreatmentCaseEditModal';
|
||||
import Pagination from '../ui/Pagination';
|
||||
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
||||
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
||||
@@ -33,6 +35,15 @@ interface PlanResponse {
|
||||
sessions: PlanSession[];
|
||||
}
|
||||
|
||||
/**
|
||||
* آینهٔ `TreatmentProtocol::MIN_STEPS/MAX_STEPS` در بکاند.
|
||||
*
|
||||
* قفلِ واقعی سمت سرور است؛ این فقط جلوی درخواستی را میگیرد که جوابش از پیش معلوم
|
||||
* است — دکمهای که میدانیم ۴۲۲ میگیرد نباید فعال بماند.
|
||||
*/
|
||||
const MIN_SESSIONS = 2;
|
||||
const MAX_SESSIONS = 60;
|
||||
|
||||
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
active: 'در جریان',
|
||||
completed: 'تمام شده',
|
||||
@@ -208,8 +219,36 @@ function CaseDetail({ summary, view, onView, onBack }: {
|
||||
}) {
|
||||
const [term, setTerm] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => setPage(1), [term, view]);
|
||||
|
||||
/**
|
||||
* کم/زیاد کردن جلسات همین دوره.
|
||||
*
|
||||
* پیش از این فقط از صفحهٔ «دورههای درمان» و پشت یک مودال ممکن بود، در حالی که
|
||||
* تصمیمش سرِ پروندهٔ بیمار گرفته میشود. سرور جلسهٔ انجامشده یا نوبتدار را حذف
|
||||
* نمیکند و ۴۰۹ برمیگرداند؛ پیام همان خطا نشان داده میشود چون دلیلش را دقیقتر
|
||||
* از هر متن ثابتی میگوید (چند جلسه قفل است).
|
||||
*/
|
||||
const setTotalSessions = useMutation({
|
||||
mutationFn: (total: number) =>
|
||||
api.patch<ApiResponse<unknown>>(`/api/v1/treatment-case/${summary.uuid}`, { total_sessions: total }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
|
||||
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || 'تغییر تعداد جلسات ناموفق بود'),
|
||||
});
|
||||
|
||||
const total = summary.total_sessions;
|
||||
const completed = summary.completed_sessions;
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
const busy = setTotalSessions.isPending;
|
||||
// کفِ واقعی، جلسات قفلشده است؛ سرور همان را با ۴۰۹ میگوید ولی دکمهٔ فعالِ
|
||||
// بیاثر بدتر از دکمهٔ خاموش است.
|
||||
const minAllowed = Math.max(MIN_SESSIONS, completed);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-case-plan', summary.uuid],
|
||||
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/api/v1/treatment-case/${summary.uuid}/plan`),
|
||||
@@ -234,9 +273,75 @@ function CaseDetail({ summary, view, onView, onBack }: {
|
||||
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
style={{ marginInlineStart: 'auto' }}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<PencilSquareIcon style={{ width: 15, height: 15 }} />
|
||||
ویرایش دوره
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* تعداد جلسات — تصمیمی که سرِ همین صفحه گرفته میشود، پس همینجا هم تغییر میکند. */}
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700 }}>تعداد جلسات</span>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn"
|
||||
aria-label="کم کردن یک جلسه"
|
||||
disabled={busy || total <= minAllowed}
|
||||
onClick={() => setTotalSessions.mutate(total - 1)}
|
||||
>
|
||||
<MinusIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
style={{ minWidth: 32, textAlign: 'center', fontSize: 15, fontWeight: 700 }}
|
||||
>
|
||||
{formatNumber(total)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn"
|
||||
aria-label="افزودن یک جلسه"
|
||||
disabled={busy || total >= MAX_SESSIONS}
|
||||
onClick={() => setTotalSessions.mutate(total + 1)}
|
||||
>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(completed)} از {formatNumber(total)} جلسه انجام شده
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-label={`پیشرفت دوره: ${completed} از ${total}`}
|
||||
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{
|
||||
width: `${percent}%`, height: '100%',
|
||||
background: summary.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{total <= minAllowed && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
||||
{completed >= total
|
||||
? 'همهٔ جلسهها انجام شدهاند؛ کم کردن ممکن نیست.'
|
||||
: `کمتر از ${formatNumber(MIN_SESSIONS)} جلسه ممکن نیست.`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="seg" style={{ alignSelf: 'start' }}>
|
||||
@@ -301,6 +406,10 @@ function CaseDetail({ summary, view, onView, onBack }: {
|
||||
<Pagination page={page} total={sessions.length} limit={PAGE_SIZE} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<TreatmentCaseEditModal caseUuid={summary.uuid} onClose={() => setEditing(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user