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:
@@ -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'] },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user