feat(course): show how the course is actually going, not just how it was planned

Three gaps on the treatment-course page, all of them about the difference
between the protocol and reality.

The sessions table listed each date but not the gap between them, leaving the
operator to subtract two Jalali dates in their head. It now shows the real gap
and colours it as a warning past the protocol maximum.

A course cancelled mid-way stretches silently: the session goes back to
planned and nobody is told. The suggestion endpoint does warn, but only once a
branch is picked, so the warning could go unseen indefinitely. The page now
derives "N days since the last session, past the protocol maximum" from the
course itself, so it shows immediately.

The course's preferred resource was applied by the engine but never named in
the UI. The API now returns preferred_resource_name alongside the uuid, and
the text says plainly that it is a preference — the engine moves it up the
list, it does not hold the slot.

Two backend tests that were owed: the stricter of the protocol spacing and a
spacing policy wins (protocol 7 days, policy 21, effective 21 — otherwise a
clinic's safety rule could be bypassed by writing a short protocol), and a
session whose earliest possible date falls outside the 90-day horizon is
skipped rather than failing book-all, leaving the course untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 13:51:34 +03:30
co-authored by Claude Opus 5
parent 83704f9d30
commit e9e61adfee
7 changed files with 267 additions and 8 deletions
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
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() } }));
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<typeof import('react-router-dom')>('react-router-dom')),
useParams: () => ({ courseUuid: 'c-1' }),
}));
import { api } from '../lib/api';
import TreatmentCoursePage from './TreatmentCoursePage';
const get = api.get as ReturnType<typeof vi.fn>;
const DAY = 86400;
const now = () => Math.floor(Date.now() / 1000);
function course(over: Record<string, unknown> = {}) {
return {
uuid: 'c-1',
service_name: 'لیزر',
status: 'active',
min_days: 20,
ideal_days: 28,
max_days: 40,
abandon_reason: null,
preferred_resource_uuid: null,
preferred_resource_name: null,
progress: { completed: 1, booked: 0, planned: 1, total: 2 },
sessions: [
{ session_number: 1, status: 'completed', slot_start: now() - 60 * DAY, completed_at: now() - 60 * DAY, params: {} },
{ session_number: 2, status: 'planned', slot_start: null, completed_at: null, params: {} },
],
...over,
};
}
function mockCourse(payload: Record<string, unknown>) {
get.mockImplementation((url: string) => {
if (url.includes('next-slot-suggestion')) return Promise.resolve({ data: null });
if (url.includes('/branch')) return Promise.resolve({ data: [] });
return Promise.resolve({ data: payload });
});
}
describe('TreatmentCoursePage', () => {
beforeEach(() => vi.clearAllMocks());
/** ⭐ دوره‌ای که وسطش لغو شده بی‌صدا کِش می‌آید؛ هشدار نباید به انتخاب شعبه وابسته باشد. */
it('warns when the course has run past the protocol maximum', async () => {
mockCourse(course());
renderWithProviders(<TreatmentCoursePage />);
expect(await screen.findByText(/روز از آخرین جلسه گذشته/)).toBeInTheDocument();
});
it('stays quiet while the course is still inside its window', async () => {
mockCourse(course({
sessions: [
{ session_number: 1, status: 'completed', slot_start: now() - 5 * DAY, completed_at: now() - 5 * DAY, params: {} },
{ session_number: 2, status: 'planned', slot_start: null, completed_at: null, params: {} },
],
}));
renderWithProviders(<TreatmentCoursePage />);
await waitFor(() => expect(get).toHaveBeenCalled());
expect(screen.queryByText(/روز از آخرین جلسه گذشته/)).toBeNull();
});
/** فاصلهٔ واقعی، نه فاصلهٔ پروتکل — تفاوتشان همان چیزی است که کِش‌آمدن را نشان می‌دهد. */
it('shows the real gap between two dated sessions', async () => {
mockCourse(course({
sessions: [
{ session_number: 1, status: 'completed', slot_start: now() - 40 * DAY, completed_at: now() - 40 * DAY, params: {} },
{ session_number: 2, status: 'completed', slot_start: now() - 10 * DAY, completed_at: now() - 10 * DAY, params: {} },
],
progress: { completed: 2, booked: 0, planned: 0, total: 2 },
}));
renderWithProviders(<TreatmentCoursePage />);
expect(await screen.findByText('۳۰ روز')).toBeInTheDocument();
});
});
+87 -1
View File
@@ -4,7 +4,7 @@ import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import { formatDate } from '../lib/utils';
import { formatDate, formatNumber } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches';
import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses';
@@ -36,6 +36,48 @@ export default function TreatmentCoursePage() {
const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined);
/**
* دوره‌ای که وسطش لغو شده، بی‌صدا کِش می‌آید: جلسه به «برنامه‌ریزی‌شده» برمی‌گردد و
* هیچ‌کس خبردار نمی‌شود.
*
* هشدارِ پیشنهاد فقط وقتی می‌آید که شعبه انتخاب شده باشد؛ این یکی از خودِ دوره حساب
* می‌شود، پس بلافاصله دیده می‌شود. مبنا آخرین جلسهٔ **دارای تاریخ** است — همان لنگری
* که پروتکل با آن فاصله می‌سنجد.
*/
const overdue = React.useMemo(() => {
if (!course || course.status !== 'active') return null;
const sessions = course.sessions ?? [];
const dated = sessions.filter((s) => s.slot_start !== null);
const remaining = sessions.filter((s) => s.status === 'planned').length;
if (dated.length === 0 || remaining === 0) return null;
const last = Math.max(...dated.map((s) => s.slot_start ?? 0));
const days = Math.floor((Date.now() / 1000 - last) / 86400);
return days > course.max_days ? days : null;
}, [course]);
/**
* فاصلهٔ **واقعی** با جلسهٔ قبلی، نه فاصلهٔ پروتکل.
*
* پروتکل می‌گوید چه باید می‌شد؛ این می‌گوید چه شد. تفاوتشان همان چیزی است که نشان
* می‌دهد دوره دارد کِش می‌آید — و بدون این ستون، اپراتور باید دو تاریخ را در ذهنش
* تفریق کند.
*/
const gapBefore = (session: CourseSessionRow): number | null => {
const dated = (course?.sessions ?? [])
.filter((s) => s.slot_start !== null)
.sort((a, b) => (a.slot_start ?? 0) - (b.slot_start ?? 0));
const index = dated.findIndex((s) => s.session_number === session.session_number);
if (index <= 0) return null;
return Math.round(((dated[index].slot_start ?? 0) - (dated[index - 1].slot_start ?? 0)) / 86400);
};
const columns: Column<CourseSessionRow>[] = [
{
key: 'session_number',
@@ -59,6 +101,26 @@ export default function TreatmentCoursePage() {
<span style={{ fontSize: 13 }}>{s.slot_start === null ? '—' : formatDate(s.slot_start)}</span>
),
},
{
key: 'gap',
header: 'فاصله با قبلی',
render: (s) => {
const gap = gapBefore(s);
if (gap === null) return <span style={{ color: 'var(--text-3)' }}></span>;
const tooLong = course !== undefined && gap > course.max_days;
return (
<span
style={{ fontSize: 13, color: tooLong ? 'var(--warning)' : undefined }}
title={tooLong ? `بیش از حداکثر ${formatNumber(course!.max_days)} روزِ پروتکل` : undefined}
>
{formatNumber(gap)} روز
</span>
);
},
},
{
key: 'params',
header: 'پارامتر',
@@ -111,6 +173,22 @@ export default function TreatmentCoursePage() {
)}
</div>
{overdue !== null && (
<span
style={{
fontSize: 13,
lineHeight: 1.8,
color: 'var(--warning)',
background: 'var(--warning-bg)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
{formatNumber(overdue)} روز از آخرین جلسه گذشته بیشتر از حداکثر{' '}
{formatNumber(course.max_days)} روزِ پروتکل. جلسهٔ بعدی را دوباره زمانبندی کنید.
</span>
)}
{course.abandon_reason && (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>دلیل رهاکردن: {course.abandon_reason}</span>
)}
@@ -150,6 +228,14 @@ export default function TreatmentCoursePage() {
{suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')}
</span>
)}
{/* ترجیح است نه الزام: موتور همان منبع را جلوتر می‌آورد ولی اگر آزاد نباشد
منبع دیگری می‌دهد. متن هم همین را می‌گوید تا انتظار اشتباه نسازد. */}
{course.preferred_resource_name && (
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>
ترجیح دوره: {course.preferred_resource_name} اگر آزاد نباشد منبع دیگری
پیشنهاد میشود.
</span>
)}
{suggestion.warning && (
<span style={{ color: 'var(--warning)' }}>{suggestion.warning}</span>
)}
+1
View File
@@ -1307,6 +1307,7 @@ export interface TreatmentCourse {
max_days: number;
patient_package_uuid: string | null;
preferred_resource_uuid: string | null;
preferred_resource_name: string | null;
status: 'active' | 'completed' | 'abandoned';
abandon_reason: string | null;
started_at: number;