feat(cancellation): cancellation policy, no-show tracking and a waitlist

Cancelling worked but had no policy behind it: no window, no penalty, nothing
happened to the deposit, and the no_show status had no effect at all.

Two rules that are expensive to get wrong, and both are load-bearing:
- The clinic cancelling its own appointment is never charged. That check is the
  first line of the calculation, not somewhere in the middle, so a later
  refactor cannot reorder it into charging patients for the clinic's decision.
- A penalty never exceeds what was actually paid. Anything above that is a
  debt, and debt belongs to billing, not to cancellation. An unpaid appointment
  is charged nothing and the response says why.

The default is no penalty at all — a penalising default would have made every
patient with a near appointment liable the moment this deployed.

No-shows are rows, not a counter on the patient: a counter loses which
appointment and when, which makes the 12-month window impossible. Crossing the
threshold adds an existing TenantTag; it never blocks the patient, because
blocking is an eligibility policy (task 09) written on top of that same tag.

Waitlist notifies up to ten matching people and the first to book wins. An
exclusive queue reads fairer but means a freed slot sits locked for half an
hour while someone ignores their phone — so the SMS says so explicitly instead.

Insufficient wallet balance does not fail the cancellation: the slot is freed
either way. A slot should not be held hostage to money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 12:06:48 +03:30
co-authored by Claude Opus 5
parent d831ce2c1c
commit fba1555f22
26 changed files with 3122 additions and 77 deletions
+4
View File
@@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import BranchesPage from './pages/BranchesPage';
import CancellationPolicyPage from './pages/CancellationPolicyPage';
import WaitlistPage from './pages/WaitlistPage';
import CourseProtocolsPage from './pages/CourseProtocolsPage';
import TreatmentCoursePage from './pages/TreatmentCoursePage';
import PackagesPage from './pages/PackagesPage';
@@ -301,6 +303,8 @@ export default function App() {
<Route path="patient-package/:patientPackageUuid/ledger" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PatientPackageLedgerPage /></RoleRoute>} />
<Route path="course-protocols" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CourseProtocolsPage /></RoleRoute>} />
<Route path="treatment-course/:courseUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><TreatmentCoursePage /></RoleRoute>} />
<Route path="cancellation-policy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><CancellationPolicyPage /></RoleRoute>} />
<Route path="waitlist" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><WaitlistPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
@@ -2,7 +2,7 @@ import React from 'react';
import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, NoSymbolIcon, QueueListIcon,
} from '@heroicons/react/24/outline';
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
@@ -35,6 +35,8 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'packages', label: 'پکیج‌ها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'cancellation', label: 'سیاست لغو', icon: NoSymbolIcon, to: '/admin/cancellation-policy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'waitlist', label: 'لیست انتظار', icon: QueueListIcon, to: '/admin/waitlist', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
+101
View File
@@ -0,0 +1,101 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CancellationPolicy, CancellationPreview, CancellationResult, WaitlistEntry } from '../types';
/**
* سیاست لغو و لیست انتظار.
*
* پیش‌نمایش لغو همیشه از سرور می‌آید — همان محاسبه‌ای که خودِ لغو انجام می‌دهد، تا عددی
* که کاربر می‌بیند با آنچه کسر می‌شود یکی باشد.
*/
const POLICY_KEY = ['cancellation-policy'];
const WAITLIST_KEY = ['waitlist'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
interface PolicyResponse {
default: CancellationPolicy | null;
overrides: CancellationPolicy[];
}
export function useCancellationPolicy() {
const qc = useQueryClient();
const query = useQuery({
queryKey: POLICY_KEY,
queryFn: () => api.get<ApiResponse<PolicyResponse>>('/api/v1/cancellation-policy'),
});
const save = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.put<ApiResponse<CancellationPolicy>>('/api/v1/cancellation-policy', body),
onSuccess: () => {
toast.success('سیاست لغو ذخیره شد');
qc.invalidateQueries({ queryKey: POLICY_KEY });
},
onError: (e) => fail(e, 'ذخیرهٔ سیاست ناموفق بود'),
});
return {
policy: query.data?.data?.default ?? null,
overrides: query.data?.data?.overrides ?? [],
loading: query.isLoading,
save,
};
}
export function useCancellationPreview(appointmentUuid: string | undefined, by: 'user' | 'doctor') {
const query = useQuery({
queryKey: ['cancellation-preview', appointmentUuid, by],
queryFn: () =>
api.get<ApiResponse<CancellationPreview>>(
`/api/v1/appointment/${appointmentUuid}/cancellation-preview?by=${by}`,
),
enabled: !!appointmentUuid,
});
return { preview: query.data?.data, loading: query.isLoading };
}
export function useCancelAppointment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ uuid, by }: { uuid: string; by: 'user' | 'doctor' }) =>
api.post<ApiResponse<CancellationResult>>(`/api/v1/appointment/${uuid}/cancel`, { by }),
onSuccess: (res) => {
const notified = res.data.waitlist_notified;
toast.success(
notified > 0 ? `نوبت لغو شد و ${notified} نفر از لیست انتظار خبر شدند` : 'نوبت لغو شد',
);
qc.invalidateQueries({ queryKey: ['appointments'] });
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
},
onError: (e) => fail(e, 'لغو نوبت ناموفق بود'),
});
}
export function useWaitlist(status?: string) {
const qc = useQueryClient();
const key = [...WAITLIST_KEY, status ?? ''];
const query = useQuery({
queryKey: key,
queryFn: () =>
api.get<ApiResponse<WaitlistEntry[]>>(`/api/v1/waitlist${status ? `?status=${status}` : ''}`),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/waitlist/${uuid}`),
onSuccess: () => {
toast.success('از لیست انتظار حذف شد');
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
},
onError: (e) => fail(e, 'حذف ناموفق بود'),
});
return { entries: query.data?.data ?? [], loading: query.isLoading, remove };
}
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, 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() } }));
import { api } from '../lib/api';
import CancellationPolicyPage from './CancellationPolicyPage';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
const policy = {
uuid: 'cp1',
service_uuid: null,
service_name: null,
free_window_hours: 24,
penalty_mode: 'percent' as const,
penalty_value: 50,
deposit_refundable: false,
credit_refundable: true,
no_show_threshold: 3,
risk_tag_uuid: null,
active: true,
created_at: 1_700_000_000,
};
describe('CancellationPolicyPage', () => {
beforeEach(() => {
vi.clearAllMocks();
get.mockResolvedValue({ success: true, data: { default: policy, overrides: [] } });
put.mockResolvedValue({ success: true, data: policy });
});
it('loads the saved policy into the form', async () => {
renderWithProviders(<CancellationPolicyPage />, { route: '/admin/cancellation-policy' });
// فیلد درصد فقط وقتی حالت «درصدی» است رندر می‌شود، پس انتظارش هم باید صبر کند.
await waitFor(() => expect(screen.getByLabelText('درصد جریمه')).toHaveValue(50));
expect(screen.getByLabelText('پنجرهٔ لغو رایگان (ساعت)')).toHaveValue(24);
expect(screen.getByLabelText('آستانهٔ عدم حضور')).toHaveValue(3);
});
/** درصد بیرون بازه نباید تا سرور برود و ۴۲۲ بگیرد؛ فرم همان‌جا می‌گوید. */
it('blocks saving a percentage above one hundred', async () => {
renderWithProviders(<CancellationPolicyPage />, { route: '/admin/cancellation-policy' });
await waitFor(() => expect(screen.getByLabelText('درصد جریمه')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('درصد جریمه'), { target: { value: '150' } });
expect(screen.getByText('درصد باید بین ۰ تا ۱۰۰ باشد.')).toBeInTheDocument();
expect(screen.getByText('ذخیرهٔ سیاست')).toBeDisabled();
expect(put).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,165 @@
import React, { useEffect, useState } from 'react';
import PageHeader from '../components/ui/PageHeader';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { usePermissions } from '../hooks/usePermissions';
import { useCancellationPolicy } from '../hooks/useCancellation';
const MODES = [
{ value: 'none', label: 'بدون جریمه' },
{ value: 'percent', label: 'درصدی' },
{ value: 'fixed', label: 'مبلغ ثابت' },
];
/**
* سیاست لغو محیط.
*
* پیش‌فرض «بدون جریمه» است و همین‌جا هم گفته می‌شود: فعال‌کردن جریمه یک تصمیم
* کسب‌وکاری است، نه چیزی که کسی تصادفی روشنش کند.
*/
export default function CancellationPolicyPage() {
const { policy, loading, save } = useCancellationPolicy();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [freeWindow, setFreeWindow] = useState(24);
const [mode, setMode] = useState<'none' | 'percent' | 'fixed'>('none');
const [value, setValue] = useState(0);
const [depositRefundable, setDepositRefundable] = useState(false);
const [creditRefundable, setCreditRefundable] = useState(true);
const [threshold, setThreshold] = useState(3);
useEffect(() => {
if (!policy) return;
setFreeWindow(policy.free_window_hours);
setMode(policy.penalty_mode);
setValue(policy.penalty_value);
setDepositRefundable(policy.deposit_refundable);
setCreditRefundable(policy.credit_refundable);
setThreshold(policy.no_show_threshold);
}, [policy]);
const percentInvalid = mode === 'percent' && (value < 0 || value > 100);
return (
<div className="fade-in">
<PageHeader
title="سیاست لغو"
description="تا چند ساعت قبل لغو رایگان است، جریمه چقدر است، و بعد از چند بار عدم حضور بیمار پرریسک علامت می‌خورد."
backTo="/admin/settings-menu"
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640 }}>
{loading && <span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری</span>}
<div className="field" style={{ maxWidth: 220 }}>
<label htmlFor="cp-window">پنجرهٔ لغو رایگان (ساعت)</label>
<input
id="cp-window"
className="input"
type="number"
min={0}
value={freeWindow}
onChange={(e) => setFreeWindow(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
لغو زودتر از این، همیشه رایگان است.
</span>
</div>
<div className="field" style={{ maxWidth: 260 }}>
<label>نوع جریمه پس از پنجرهٔ رایگان</label>
<SearchableSelect
value={mode}
onChange={(v) => setMode((v as 'none' | 'percent' | 'fixed') ?? 'none')}
options={MODES}
/>
</div>
{mode === 'percent' && (
<div className="field" style={{ maxWidth: 200 }}>
<label htmlFor="cp-percent">درصد جریمه</label>
<input
id="cp-percent"
className="input"
type="number"
min={0}
max={100}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
/>
{percentInvalid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>درصد باید بین ۰ تا ۱۰۰ باشد.</span>
)}
</div>
)}
{mode === 'fixed' && (
<div className="field" style={{ maxWidth: 260 }}>
<label>مبلغ جریمه</label>
<PriceInput value={value} onChange={setValue} suffix="ریال" />
</div>
)}
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
جریمه هرگز از مبلغ پرداختی بیمار بیشتر نمیشود؛ نوبت نقدی جریمهای ندارد.
</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={depositRefundable}
onChange={(e) => setDepositRefundable(e.target.checked)}
/>
بیعانه پس از پنجرهٔ رایگان هم برمیگردد
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={creditRefundable}
onChange={(e) => setCreditRefundable(e.target.checked)}
/>
اعتبار پکیج پس از لغو برمیگردد
</label>
<div className="field" style={{ maxWidth: 220 }}>
<label htmlFor="cp-threshold">آستانهٔ عدم حضور</label>
<input
id="cp-threshold"
className="input"
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
بعد از این تعداد در یک سال، بیمار برچسب پرریسک میگیرد ولی مسدود نمیشود.
</span>
</div>
{canManage && (
<div>
<button
type="button"
className="btn primary"
disabled={percentInvalid || save.isPending}
onClick={() =>
save.mutate({
free_window_hours: freeWindow,
penalty_mode: mode,
penalty_value: value,
deposit_refundable: depositRefundable,
credit_refundable: creditRefundable,
no_show_threshold: threshold,
})
}
>
ذخیرهٔ سیاست
</button>
</div>
)}
</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import React, { useMemo } from 'react';
import { TrashIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import SearchableSelect from '../components/ui/SearchableSelect';
import { formatDate } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useWaitlist } from '../hooks/useCancellation';
import type { WaitlistEntry } from '../types';
const STATUS: Record<WaitlistEntry['status'], { label: string; className: string }> = {
waiting: { label: 'در انتظار', className: 'badge' },
notified: { label: 'خبر داده شد', className: 'badge amber' },
converted: { label: 'رزرو شد', className: 'badge green' },
expired: { label: 'منقضی', className: 'badge red' },
};
/**
* لیست انتظار.
*
* ستون «تعداد اطلاع» عمداً دیده می‌شود: وقتی ظرفیتی آزاد می‌شود همه خبر می‌گیرند و
* اولین رزروکننده می‌برد، پس اپراتور باید بداند چه کسی چند بار خبر شده.
*/
export default function WaitlistPage() {
const [urlState, setUrlState] = useUrlState({ search: '', status: '' });
const { entries, loading, remove } = useWaitlist(urlState.status || undefined);
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const rows = useMemo(() => {
const q = urlState.search.trim();
return entries.filter((e) => q === '' || e.service_name.includes(q));
}, [entries, urlState.search]);
const columns: Column<WaitlistEntry>[] = [
{
key: 'service_name',
header: 'خدمت',
render: (e) => <span style={{ fontWeight: 600 }}>{e.service_name}</span>,
},
{
key: 'window',
header: 'بازهٔ دلخواه',
render: (e) => (
<span style={{ fontSize: 13 }}>
{formatDate(e.desired_from)} تا {formatDate(e.desired_to)}
</span>
),
},
{
key: 'preferred_day_parts',
header: 'زمان ترجیحی',
render: (e) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{e.preferred_day_parts.length === 0 ? 'بی‌تفاوت' : e.preferred_day_parts.join('، ')}
</span>
),
},
{
key: 'notify_count',
header: 'تعداد اطلاع',
render: (e) => (
<span style={{ fontSize: 13 }}>
{e.notify_count}
{e.notified_at !== null && (
<span style={{ fontSize: 11, color: 'var(--text-3)' }}> · {formatDate(e.notified_at)}</span>
)}
</span>
),
},
{
key: 'status',
header: 'وضعیت',
render: (e) => (
<span className={STATUS[e.status].className}>
<span className="bdot" />
{STATUS[e.status].label}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title="لیست انتظار"
description="وقتی ظرفیتی آزاد می‌شود همهٔ منتظرانِ آن بازه خبر می‌گیرند و اولین رزروکننده آن را می‌برد."
backTo="/admin/settings-menu"
/>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در خدمات..."
emptyMessage="کسی در لیست انتظار نیست"
headerExtra={
<div style={{ minWidth: 180, marginRight: 'auto' }}>
<SearchableSelect
value={urlState.status}
onChange={(v) => setUrlState({ status: String(v ?? '') })}
options={[
{ value: '', label: 'همهٔ وضعیت‌ها' },
...Object.entries(STATUS).map(([value, meta]) => ({ value, label: meta.label })),
]}
placeholder="وضعیت"
/>
</div>
}
actions={(e) =>
canManage ? (
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => remove.mutate(e.uuid)}
aria-label="حذف از لیست انتظار"
>
<TrashIcon style={{ width: 15 }} />
</button>
) : null
}
/>
</div>
</div>
);
}
+50
View File
@@ -1323,3 +1323,53 @@ export interface NextSlotSuggestion {
suggested_slots: { start: number; end: number }[];
warning: string | null;
}
// ── لغو و لیست انتظار (تسک ۱۳) ───────────────────────────────────────────────
export interface CancellationPolicy {
uuid: string;
service_uuid: string | null;
service_name: string | null;
free_window_hours: number;
penalty_mode: 'none' | 'percent' | 'fixed';
penalty_value: number;
deposit_refundable: boolean;
credit_refundable: boolean;
no_show_threshold: number;
risk_tag_uuid: string | null;
active: boolean;
created_at: number;
}
export interface CancellationPreview {
penalty_rials: number;
deposit_refundable: boolean;
credit_refundable: boolean;
within_free_window: boolean;
notes: string[];
paid_rials: number;
}
export interface CancellationResult extends Omit<CancellationPreview, 'paid_rials'> {
appointment_uuid: string;
status: string;
released_resources: number;
waitlist_notified: number;
penalty_charged: boolean;
}
export interface WaitlistEntry {
uuid: string;
patient_uuid: string;
service_uuid: string;
service_name: string;
branch_id: number | null;
desired_from: number;
desired_to: number;
preferred_day_parts: string[];
priority: number;
status: 'waiting' | 'notified' | 'converted' | 'expired';
notified_at: number | null;
notify_count: number;
created_at: number;
}