Remove unused pages: PolicySimulationPage, ResourceUtilizationPage, TreatmentCoursePage, WaitlistPage, and their associated tests

This commit is contained in:
hamed
2026-08-01 20:25:42 +03:30
parent 65d5831c64
commit 9486721fa3
21 changed files with 0 additions and 3420 deletions
@@ -1,96 +0,0 @@
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<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const preview = (over: Record<string, unknown> = {}) => ({
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(
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
);
// مبلغ‌ها به تومان نمایش داده می‌شوند: ۲۵۰٬۰۰۰ ریال ⇒ ۲۵٬۰۰۰ تومان.
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(
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
);
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(
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
);
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(
<CancelAppointmentDialog open appointmentUuid="a-1" onClose={() => {}} />,
);
await waitFor(() =>
expect(screen.getByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument(),
);
expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled();
});
});
-138
View File
@@ -1,138 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CourseProtocol, NextSlotSuggestion, TreatmentCourse } from '../types';
/**
* پروتکل دوره و دورهٔ بیمار.
*
* `progress` هرگز در فرانت حساب نمی‌شود — سرور می‌دهدش، چون همان‌جاست که وضعیت جلسات
* منبع حقیقت است.
*/
const PROTOCOLS_KEY = ['course-protocols'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function useCourseProtocols() {
const qc = useQueryClient();
const query = useQuery({
queryKey: PROTOCOLS_KEY,
queryFn: () => api.get<ApiResponse<CourseProtocol[]>>('/api/v1/course-protocols'),
});
const invalidate = () => qc.invalidateQueries({ queryKey: PROTOCOLS_KEY });
const create = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post<ApiResponse<CourseProtocol>>('/api/v1/course-protocols', body),
onSuccess: () => {
toast.success('پروتکل ساخته شد');
invalidate();
},
onError: (e) => fail(e, 'ساخت پروتکل ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<CourseProtocol>>(`/api/v1/course-protocol/${uuid}`, body),
onSuccess: () => {
toast.success('پروتکل به‌روزرسانی شد');
invalidate();
},
onError: (e) => fail(e, 'به‌روزرسانی پروتکل ناموفق بود'),
});
const deactivate = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<CourseProtocol>>(`/api/v1/course-protocol/${uuid}`),
onSuccess: () => {
toast.success('پروتکل غیرفعال شد');
invalidate();
},
onError: (e) => fail(e, 'غیرفعال‌سازی ناموفق بود'),
});
return { protocols: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate };
}
export function usePatientCourses(patientUuid: string | undefined) {
const qc = useQueryClient();
const key = ['patient-courses', patientUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<TreatmentCourse[]>>(`/api/v1/patient/${patientUuid}/courses`),
enabled: !!patientUuid,
});
const start = useMutation({
mutationFn: (body: { protocol_uuid: string; patient_package_uuid?: string }) =>
api.post<ApiResponse<TreatmentCourse>>('/api/v1/treatment-course', {
patient_uuid: patientUuid,
...body,
}),
onSuccess: () => {
toast.success('دوره شروع شد');
qc.invalidateQueries({ queryKey: key });
},
onError: (e) => fail(e, 'شروع دوره ناموفق بود'),
});
return { courses: query.data?.data ?? [], loading: query.isLoading, start };
}
export function useTreatmentCourse(courseUuid: string | undefined) {
const qc = useQueryClient();
const key = ['treatment-course', courseUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<TreatmentCourse>>(`/api/v1/treatment-course/${courseUuid}`),
enabled: !!courseUuid,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({ queryKey: ['patient-courses'] });
};
const abandon = useMutation({
mutationFn: (reason: string) =>
api.post<ApiResponse<TreatmentCourse>>(`/api/v1/treatment-course/${courseUuid}/abandon`, { reason }),
onSuccess: () => {
toast.success('دوره رها شد');
invalidate();
},
onError: (e) => fail(e, 'رهاکردن دوره ناموفق بود'),
});
const bookAll = useMutation({
mutationFn: (body: { branch_uuid: string; doctor_uuid: string }) =>
api.post<ApiResponse<{ booked: number; remaining: number; message: string | null }>>(
`/api/v1/treatment-course/${courseUuid}/book-all`,
body,
),
onSuccess: (res) => {
toast.success(res.data.message ?? `${res.data.booked} جلسه رزرو شد`);
invalidate();
},
onError: (e) => fail(e, 'رزرو جلسات ناموفق بود'),
});
return { course: query.data?.data, loading: query.isLoading, abandon, bookAll };
}
export function useNextSlotSuggestion(courseUuid: string | undefined, branchUuid: string | undefined) {
const query = useQuery({
queryKey: ['course-next-slot', courseUuid, branchUuid],
queryFn: () =>
api.get<ApiResponse<NextSlotSuggestion>>(
`/api/v1/treatment-course/${courseUuid}/next-slot-suggestion?branch_uuid=${branchUuid}`,
),
enabled: !!courseUuid && !!branchUuid,
});
return { suggestion: query.data?.data, loading: query.isLoading };
}
-120
View File
@@ -1,120 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CreditLedger, PackageDefinition, PatientPackage } from '../types';
/**
* پکیج و اعتبار جلسات.
*
* مانده هیچ‌جا کش نمی‌شود و بعد از هر تغییر دفتر، کوئری باطل می‌شود: عددی که کاربر
* می‌بیند باید همان جمع دفتر باشد، نه چیزی که فرانت حساب کرده.
*/
const PACKAGES_KEY = ['packages'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function usePackages() {
const qc = useQueryClient();
const query = useQuery({
queryKey: PACKAGES_KEY,
queryFn: () => api.get<ApiResponse<PackageDefinition[]>>('/api/v1/packages'),
});
const invalidate = () => qc.invalidateQueries({ queryKey: PACKAGES_KEY });
const create = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post<ApiResponse<PackageDefinition>>('/api/v1/packages', body),
onSuccess: () => {
toast.success('پکیج ساخته شد');
invalidate();
},
onError: (e) => fail(e, 'ساخت پکیج ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<PackageDefinition>>(`/api/v1/package/${uuid}`, body),
onSuccess: () => {
toast.success('پکیج به‌روزرسانی شد');
invalidate();
},
onError: (e) => fail(e, 'به‌روزرسانی پکیج ناموفق بود'),
});
/** حذف = غیرفعال کردن؛ پکیج فروخته‌شده حذف‌شدنی نیست. */
const deactivate = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<PackageDefinition>>(`/api/v1/package/${uuid}`),
onSuccess: () => {
toast.success('پکیج غیرفعال شد');
invalidate();
},
onError: (e) => fail(e, 'غیرفعال‌سازی ناموفق بود'),
});
return { packages: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate };
}
export function usePatientPackages(patientUuid: string | undefined) {
const qc = useQueryClient();
const key = ['patient-packages', patientUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<PatientPackage[]>>(`/api/v1/patient/${patientUuid}/packages`),
enabled: !!patientUuid,
});
const sell = useMutation({
mutationFn: (body: { package_uuid: string; price_paid_rials?: number }) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient/${patientUuid}/package`, body),
onSuccess: () => {
toast.success('پکیج برای بیمار ثبت شد');
qc.invalidateQueries({ queryKey: key });
},
onError: (e) => fail(e, 'ثبت پکیج ناموفق بود'),
});
return { patientPackages: query.data?.data ?? [], loading: query.isLoading, sell };
}
export function useCreditLedger(patientPackageUuid: string | undefined) {
const qc = useQueryClient();
const key = ['credit-ledger', patientPackageUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<CreditLedger>>(`/api/v1/patient-package/${patientPackageUuid}/ledger`),
enabled: !!patientPackageUuid,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({ queryKey: ['patient-packages'] });
};
const adjust = useMutation({
mutationFn: (body: { delta: number; reason: string }) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient-package/${patientPackageUuid}/adjust`, body),
onSuccess: () => {
toast.success('اصلاح اعتبار ثبت شد');
invalidate();
},
onError: (e) => fail(e, 'اصلاح اعتبار ناموفق بود'),
});
const expire = useMutation({
mutationFn: (reason: string) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient-package/${patientPackageUuid}/expire`, { reason }),
onSuccess: () => {
toast.success('پکیج ابطال شد');
invalidate();
},
onError: (e) => fail(e, 'ابطال پکیج ناموفق بود'),
});
return { ledger: query.data?.data, loading: query.isLoading, adjust, expire };
}
-33
View File
@@ -1,33 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { api, type ApiResponse } from '../lib/api';
import type { AccuracyRow, ReportEnvelope, UtilizationRow } from '../types';
/**
* گزارش‌های بهره‌وری و دقت برنامه.
*
* بازه همیشه صریح فرستاده می‌شود تا نمودار با پیش‌فرض سرور جابه‌جا نشود.
*/
export function useResourceUtilization(branchUuid: string | undefined, from: number, to: number) {
const query = useQuery({
queryKey: ['resource-utilization', branchUuid, from, to],
queryFn: () =>
api.get<ApiResponse<ReportEnvelope<UtilizationRow>>>(
`/api/v1/reports/resource-utilization?branch_uuid=${branchUuid}&from=${from}&to=${to}`,
),
enabled: !!branchUuid,
});
return { rows: query.data?.data?.rows ?? [], loading: query.isLoading };
}
export function usePlanAccuracy(from: number, to: number) {
const query = useQuery({
queryKey: ['plan-accuracy', from, to],
queryFn: () =>
api.get<ApiResponse<ReportEnvelope<AccuracyRow>>>(
`/api/v1/reports/plan-accuracy?from=${from}&to=${to}`,
),
});
return { rows: query.data?.data?.rows ?? [], loading: query.isLoading };
}
@@ -1,61 +0,0 @@
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();
});
});
@@ -1,216 +0,0 @@
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';
import { Link } from 'react-router-dom';
import { formatNumber, formatRial } from '../lib/utils';
const MODES = [
{ value: 'none', label: 'بدون جریمه' },
{ value: 'percent', label: 'درصدی' },
{ value: 'fixed', label: 'مبلغ ثابت' },
];
/**
* سیاست لغو محیط.
*
* پیش‌فرض «بدون جریمه» است و همین‌جا هم گفته می‌شود: فعال‌کردن جریمه یک تصمیم
* کسب‌وکاری است، نه چیزی که کسی تصادفی روشنش کند.
*/
export default function CancellationPolicyPage() {
const { policy, overrides, 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 card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640 }}>
{loading && <span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری</span>}
<div className="field-block" 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-block" 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-block" 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-block" 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-block" 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 className="card card-pad" style={{ marginTop: 16, maxWidth: 640 }}>
<h3 style={{ fontSize: 14, margin: '0 0 4px' }}>سیاستهای اختصاصی خدمات</h3>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 12px', lineHeight: 1.9 }}>
خدمتی که سیاست خودش را دارد، **کاملاً** از آن پیروی میکند و سیاست بالا رویش
اثری ندارد. ساخت و ویرایشش از صفحهٔ همان خدمت انجام میشود.
</p>
{overrides.length === 0 ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
هیچ خدمتی سیاست اختصاصی ندارد؛ همه از سیاست بالا پیروی میکنند.
</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
{overrides.map((o) => (
<div
key={o.uuid}
style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}
>
<Link
to={`/admin/clinic-services/${o.service_uuid}`}
style={{ fontWeight: 600, color: 'var(--primary)' }}
>
{o.service_name ?? o.service_uuid}
</Link>
<span style={{ color: 'var(--text-2)' }}>
رایگان تا {formatNumber(o.free_window_hours)} ساعت قبل
</span>
<span style={{ color: 'var(--text-2)' }}>
{o.penalty_mode === 'none'
? 'بدون جریمه'
: o.penalty_mode === 'percent'
? `جریمه ${formatNumber(o.penalty_value)}٪`
: `جریمه ${formatRial(o.penalty_value)}`}
</span>
{!o.active && (
<span className="badge red">
<span className="bdot" />
غیرفعال
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
-349
View File
@@ -1,349 +0,0 @@
import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useCourseProtocols } from '../hooks/useCourses';
import { api, type ApiResponse } from '../lib/api';
import type { CourseProtocol, ServiceItem } from '../types';
interface StepDraft {
session_number: number;
energy: string;
}
interface Draft {
uuid?: string;
service_uuid: string;
session_count: number;
min_days: number;
ideal_days: number;
max_days: number;
prefer_same_resource: boolean;
steps: StepDraft[];
}
const EMPTY: Draft = {
service_uuid: '',
session_count: 6,
min_days: 21,
ideal_days: 28,
max_days: 45,
prefer_same_resource: true,
steps: [],
};
/**
* پروتکل دوره per سرویس.
*
* سه فاصله سه معنا دارند و ترتیبشان اجباری است؛ فرم همان‌جا می‌گوید، نه اینکه بگذارد
* کاربر ذخیره کند و ۴۲۲ بگیرد.
*/
export default function CourseProtocolsPage() {
const { protocols, loading, create, update, deactivate } = useCourseProtocols();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [draft, setDraft] = useState<Draft | null>(null);
const { data: servicesData } = useQuery({
queryKey: ['service-items-for-courses'],
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
staleTime: 60_000,
});
const services = servicesData?.data ?? [];
const rows = useMemo(() => {
const q = urlState.search.trim();
return protocols.filter((p) => q === '' || p.service_name.includes(q));
}, [protocols, urlState.search]);
const orderInvalid = draft !== null && !(draft.min_days <= draft.ideal_days && draft.ideal_days <= draft.max_days);
const columns: Column<CourseProtocol>[] = [
{
key: 'service_name',
header: 'سرویس',
render: (p) => <span style={{ fontWeight: 600 }}>{p.service_name}</span>,
},
{
key: 'session_count',
header: 'تعداد جلسه',
render: (p) => <span style={{ fontSize: 13 }}>{p.session_count}</span>,
},
{
key: 'spacing',
header: 'فاصله (روز)',
render: (p) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
حداقل {p.min_days} · ایدهآل {p.ideal_days} · حداکثر {p.max_days}
</span>
),
},
{
key: 'steps',
header: 'پارامتر جلسات',
render: (p) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.steps.length === 0 ? '—' : `${p.steps.length} جلسه پارامتر دارد`}
</span>
),
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
const save = async () => {
if (!draft) return;
const body = {
service_uuid: draft.service_uuid,
session_count: draft.session_count,
min_days: draft.min_days,
ideal_days: draft.ideal_days,
max_days: draft.max_days,
prefer_same_resource: draft.prefer_same_resource,
steps: draft.steps
.filter((s) => s.energy.trim() !== '')
.map((s) => ({ session_number: s.session_number, params: { energy: Number(s.energy) } })),
};
if (draft.uuid) {
await update.mutateAsync({ uuid: draft.uuid, body });
} else {
await create.mutateAsync(body);
}
setDraft(null);
};
return (
<div className="fade-in">
<PageHeader
title="پروتکل دوره"
description="دورهٔ چندجلسه‌ای هر خدمت: تعداد جلسه و فاصلهٔ مجاز بین جلسات."
backTo="/admin/settings-menu"
action={
canManage ? (
<button type="button" className="btn primary sm" onClick={() => setDraft({ ...EMPTY, steps: [] })}>
<PlusIcon style={{ width: 15 }} /> پروتکل تازه
</button>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در پروتکل‌ها..."
emptyMessage="هنوز پروتکلی تعریف نشده است"
actions={(p) =>
canManage ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
uuid: p.uuid,
service_uuid: p.service_uuid,
session_count: p.session_count,
min_days: p.min_days,
ideal_days: p.ideal_days,
max_days: p.max_days,
prefer_same_resource: p.prefer_same_resource,
steps: p.steps.map((s) => ({
session_number: s.session_number,
energy: String(s.params.energy ?? ''),
})),
})
}
>
ویرایش
</button>
{p.active && (
<button
type="button"
className="btn secondary sm"
disabled={deactivate.isPending}
onClick={() => deactivate.mutate(p.uuid)}
>
غیرفعال
</button>
)}
</div>
) : null
}
/>
<Modal
open={draft !== null}
title={draft?.uuid ? 'ویرایش پروتکل' : 'پروتکل تازه'}
onClose={() => setDraft(null)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={
!draft ||
(!draft.uuid && draft.service_uuid === '') ||
draft.session_count < 2 ||
orderInvalid ||
create.isPending ||
update.isPending
}
onClick={save}
>
ذخیره
</button>
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
انصراف
</button>
</>
}
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{!draft.uuid && (
<div className="field-block">
<label>سرویس</label>
<SearchableSelect
value={draft.service_uuid}
onChange={(v) => setDraft({ ...draft, service_uuid: String(v ?? '') })}
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="انتخاب سرویس"
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>هر سرویس یک پروتکل دارد.</span>
</div>
)}
<div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="cp-sessions">تعداد جلسه</label>
<input
id="cp-sessions"
className="input"
type="number"
min={2}
value={draft.session_count}
onChange={(e) => setDraft({ ...draft, session_count: Number(e.target.value) })}
/>
{draft.session_count < 2 && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
دورهٔ کمتر از دو جلسه همان نوبت تکی است.
</span>
)}
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{([
['min_days', 'حداقل (روز)'],
['ideal_days', 'ایده‌آل (روز)'],
['max_days', 'حداکثر (روز)'],
] as const).map(([key, label]) => (
<div className="field-block" key={key} style={{ maxWidth: 150 }}>
<label htmlFor={`cp-${key}`}>{label}</label>
<input
id={`cp-${key}`}
className="input"
type="number"
min={1}
value={draft[key]}
onChange={(e) => setDraft({ ...draft, [key]: Number(e.target.value) })}
/>
</div>
))}
</div>
{orderInvalid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
ترتیب باید حداقل ایدهآل حداکثر باشد.
</span>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={draft.prefer_same_resource}
onChange={(e) => setDraft({ ...draft, prefer_same_resource: e.target.checked })}
/>
تا حد امکان همان منبع جلسهٔ قبل
</label>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>پارامتر جلسات (اختیاری)</h3>
{draft.steps.map((step, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
className="input"
style={{ maxWidth: 110 }}
type="number"
min={1}
max={draft.session_count}
value={step.session_number}
onChange={(e) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) =>
i === index ? { ...s, session_number: Number(e.target.value) } : s,
),
})
}
/>
<input
className="input"
style={{ maxWidth: 140 }}
type="number"
placeholder="سطح انرژی"
value={step.energy}
onChange={(e) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) => (i === index ? { ...s, energy: e.target.value } : s)),
})
}
/>
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, i) => i !== index) })}
aria-label="حذف پارامتر"
>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))}
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
...draft,
steps: [...draft.steps, { session_number: draft.steps.length + 1, energy: '' }],
})
}
>
<PlusIcon style={{ width: 15 }} /> افزودن پارامتر جلسه
</button>
</div>
</div>
)}
</Modal>
</div>
);
}
-279
View File
@@ -1,279 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { formatRial } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { usePackages } from '../hooks/usePackages';
import { api, type ApiResponse } from '../lib/api';
import { useQuery } from '@tanstack/react-query';
import type { PackageDefinition, ServiceItem } from '../types';
interface Draft {
uuid?: string;
name: string;
session_count: number;
price_rials: number;
validity_days: number | '';
service_uuids: string[];
}
const EMPTY: Draft = { name: '', session_count: 6, price_rials: 0, validity_days: '', service_uuids: [] };
/**
* تعریف پکیج‌ها.
*
* ماندهٔ بیمار اینجا نیست — آن در پروندهٔ بیمار است. اینجا فقط «چه می‌فروشیم».
*/
export default function PackagesPage() {
const { packages, loading, create, update, deactivate } = usePackages();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [draft, setDraft] = useState<Draft | null>(null);
const { data: servicesData } = useQuery({
queryKey: ['service-items-for-packages'],
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
staleTime: 60_000,
});
const services = servicesData?.data ?? [];
const rows = useMemo(() => {
const q = urlState.search.trim();
return packages.filter((p) => q === '' || p.name.includes(q));
}, [packages, urlState.search]);
const columns: Column<PackageDefinition>[] = [
{
key: 'name',
header: 'پکیج',
render: (p) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{p.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.services.map((s) => s.name).join('، ') || 'بدون سرویس'}
</span>
</div>
),
},
{
key: 'session_count',
header: 'تعداد جلسه',
render: (p) => <span style={{ fontSize: 13 }}>{p.session_count}</span>,
},
{
key: 'price_rials',
header: 'قیمت',
render: (p) => <span style={{ fontSize: 13 }}>{formatRial(p.price_rials)}</span>,
},
{
key: 'validity_days',
header: 'اعتبار',
render: (p) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
{p.validity_days === null ? 'بی‌پایان' : `${p.validity_days} روز`}
</span>
),
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
const save = async () => {
if (!draft) return;
const body = {
name: draft.name,
session_count: draft.session_count,
price_rials: draft.price_rials,
validity_days: draft.validity_days === '' ? null : draft.validity_days,
service_uuids: draft.service_uuids,
};
if (draft.uuid) {
await update.mutateAsync({ uuid: draft.uuid, body });
} else {
await create.mutateAsync(body);
}
setDraft(null);
};
return (
<div className="fade-in">
<PageHeader
title="پکیج‌ها"
description="بستهٔ چندجلسه‌ای که بیمار یکجا می‌خرد و بعداً جلساتش را رزرو می‌کند."
backTo="/admin/settings-menu"
action={
canManage ? (
<button type="button" className="btn primary sm" onClick={() => setDraft({ ...EMPTY })}>
<PlusIcon style={{ width: 15 }} /> پکیج تازه
</button>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در پکیج‌ها..."
emptyMessage="هنوز پکیجی تعریف نشده است"
actions={(p) =>
canManage ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
uuid: p.uuid,
name: p.name,
session_count: p.session_count,
price_rials: p.price_rials,
validity_days: p.validity_days ?? '',
service_uuids: p.services.map((s) => s.uuid),
})
}
>
ویرایش
</button>
{p.active && (
<button
type="button"
className="btn secondary sm"
disabled={deactivate.isPending}
onClick={() => deactivate.mutate(p.uuid)}
>
غیرفعال
</button>
)}
</div>
) : null
}
/>
<Modal
open={draft !== null}
title={draft?.uuid ? 'ویرایش پکیج' : 'پکیج تازه'}
onClose={() => setDraft(null)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={!draft?.name.trim() || draft.service_uuids.length === 0 || create.isPending || update.isPending}
onClick={save}
>
ذخیره
</button>
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
انصراف
</button>
</>
}
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field-block">
<label htmlFor="pkg-name">نام پکیج</label>
<input
id="pkg-name"
className="input"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="۶ جلسه لیزر فول‌بادی"
/>
</div>
<div className="field-block">
<label htmlFor="pkg-sessions">تعداد جلسه</label>
<input
id="pkg-sessions"
className="input"
type="number"
min={1}
value={draft.session_count}
onChange={(e) => setDraft({ ...draft, session_count: Number(e.target.value) })}
/>
</div>
<div className="field-block">
<label>قیمت</label>
<PriceInput
value={draft.price_rials}
onChange={(v) => setDraft({ ...draft, price_rials: v })}
suffix="ریال"
/>
</div>
<div className="field-block">
<label htmlFor="pkg-validity">اعتبار (روز)</label>
<input
id="pkg-validity"
className="input"
type="number"
min={1}
value={draft.validity_days}
onChange={(e) =>
setDraft({ ...draft, validity_days: e.target.value === '' ? '' : Number(e.target.value) })
}
placeholder="خالی یعنی بی‌پایان"
/>
</div>
<div className="field-block">
<label>سرویسهای پوششدادهشده</label>
<SearchableSelect
value={null}
onChange={(v) => {
const uuid = String(v ?? '');
if (uuid && !draft.service_uuids.includes(uuid)) {
setDraft({ ...draft, service_uuids: [...draft.service_uuids, uuid] });
}
}}
options={services
.filter((s) => !draft.service_uuids.includes(s.uuid))
.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="افزودن سرویس"
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{draft.service_uuids.map((uuid) => (
<button
key={uuid}
type="button"
className="badge"
onClick={() =>
setDraft({ ...draft, service_uuids: draft.service_uuids.filter((u) => u !== uuid) })
}
>
{services.find((s) => s.uuid === uuid)?.name ?? uuid}
</button>
))}
</div>
{draft.service_uuids.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
پکیج بدون سرویس قابل مصرف نیست.
</span>
)}
</div>
</div>
)}
</Modal>
</div>
);
}
@@ -1,78 +0,0 @@
import { describe, it, expect, beforeEach, vi } 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() } }));
import { Routes, Route } from 'react-router-dom';
import { api } from '../lib/api';
import PatientPackageLedgerPage from './PatientPackageLedgerPage';
const get = api.get as ReturnType<typeof vi.fn>;
const ledger = {
package: {
uuid: 'pp1',
package_uuid: 'p1',
package_name: '۶ جلسه لیزر',
patient_uuid: 'pat1',
session_count: 6,
price_paid_rials: 25_000_000,
purchased_at: 1_700_000_000,
valid_to: null,
expired: false,
balance: 5,
},
rows: [
{
uuid: 'l1', kind: 'purchase', delta: 6, appointment_uuid: null, service_uuid: null,
service_name: null, reason: 'خرید پکیج', created_by: 'u1', created_at: 1_700_000_000,
running_balance: 6,
},
{
uuid: 'l2', kind: 'consume', delta: -1, appointment_uuid: 'a1', service_uuid: 's1',
service_name: 'لیزر', reason: null, created_by: null, created_at: 1_700_100_000,
running_balance: 5,
},
],
};
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/patient-package/:patientPackageUuid/ledger" element={<PatientPackageLedgerPage />} />
</Routes>,
{ route: '/admin/patient-package/pp1/ledger' },
);
}
describe('PatientPackageLedgerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
get.mockResolvedValue({ success: true, data: ledger });
});
/** ماندهٔ هر ردیف باید دیده شود — همان چیزی که یک شمارنده نمی‌تواند نشان دهد. */
it('shows every ledger row with its running balance', async () => {
renderPage();
await waitFor(() => expect(screen.getByText('خرید')).toBeInTheDocument());
expect(screen.getByText('مصرف در نوبت')).toBeInTheDocument();
expect(screen.getByText('+6')).toBeInTheDocument();
expect(screen.getByText('-1')).toBeInTheDocument();
});
it('summarises the balance against the purchased session count', async () => {
renderPage();
await waitFor(() => expect(screen.getByText(/از 6 جلسه/)).toBeInTheDocument());
// عدد مانده در همان جمله می‌آید؛ «۵ از ۶» یعنی یک جلسه مصرف شده.
expect(screen.getByText(/از 6 جلسه/).parentElement).toHaveTextContent('5');
});
});
@@ -1,220 +0,0 @@
import React, { useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import { formatDate, formatRial } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import { useCreditLedger } from '../hooks/usePackages';
import type { CreditLedgerRow } from '../types';
const KIND_LABELS: Record<CreditLedgerRow['kind'], string> = {
purchase: 'خرید',
consume: 'مصرف در نوبت',
refund: 'بازگشت با لغو',
adjustment: 'اصلاح دستی',
expiry: 'انقضا',
};
/**
* دفتر اعتبار یک پکیج خریداری‌شده.
*
* ستون «مانده» در هر ردیف نشان می‌دهد عدد نهایی از کجا آمده — همان چیزی که یک
* شمارندهٔ ذخیره‌شده هرگز نمی‌تواند نشان دهد.
*/
export default function PatientPackageLedgerPage() {
const { patientPackageUuid } = useParams<{ patientPackageUuid: string }>();
const { ledger, loading, adjust, expire } = useCreditLedger(patientPackageUuid);
const { can } = usePermissions();
const canCorrect = can('appointment_settings', 'update');
const [adjusting, setAdjusting] = useState(false);
const [delta, setDelta] = useState(1);
const [reason, setReason] = useState('');
/**
* مانده تجمعی از روی همان ردیف‌هایی که نمایش داده می‌شوند.
*
* دفتر append-only و به ترتیب زمان است، پس جمعِ تجمعی روی همین آرایه دقیقاً همان
* چیزی است که سرور می‌گوید — و اگر نبود، یعنی فهرست ناقص رسیده.
*/
const runningBalance = useMemo(() => {
const out: Record<string, number> = {};
let total = 0;
for (const row of [...(ledger?.rows ?? [])].sort((a, b) => a.created_at - b.created_at)) {
total += row.delta;
out[row.uuid] = total;
}
return out;
}, [ledger?.rows]);
const columns: Column<CreditLedgerRow>[] = [
{
key: 'created_at',
header: 'تاریخ',
render: (r) => <span style={{ fontSize: 13 }}>{formatDate(r.created_at)}</span>,
},
{
key: 'kind',
header: 'نوع',
render: (r) => <span style={{ fontSize: 13 }}>{KIND_LABELS[r.kind]}</span>,
},
{
key: 'delta',
header: 'تغییر',
render: (r) => (
<span style={{ fontSize: 13, fontWeight: 600, color: r.delta > 0 ? 'var(--success)' : 'var(--danger)' }}>
{r.delta > 0 ? `+${r.delta}` : r.delta}
</span>
),
},
{
key: 'running_balance',
header: 'مانده',
// ⚠️ در UI جمع می‌شود، نه از سرور خوانده.
//
// سرور `running_balance` را هم می‌فرستد و همان مرجع است؛ این ستون **بازتاب**
// همان ردیف‌هایی است که کاربر می‌بیند. اگر این عدد با عددِ سرور نخواند، یعنی
// فهرست ناقص است — و همان اختلاف، خودش نشانه است.
render: (r) => <span style={{ fontSize: 13 }}>{runningBalance[r.uuid] ?? r.running_balance}</span>,
},
{
key: 'reason',
header: 'دلیل',
render: (r) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{r.reason ?? r.service_name ?? '—'}
</span>
),
},
{
// «چه کسی» و «کدام نوبت» از قبل در پاسخ بودند و نمایش داده نمی‌شدند. دفترِ
// اصلاح‌پذیر بدون نامِ اصلاح‌کننده، نصف حسابرسی است.
key: 'created_by',
header: 'ثبت‌کننده',
render: (r) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.created_by ?? 'سیستم'}</span>
),
},
{
key: 'appointment_uuid',
header: 'نوبت',
render: (r) =>
r.appointment_uuid ? (
<Link
to={`/admin/appointments/${r.appointment_uuid}`}
style={{ fontSize: 12, color: 'var(--primary)' }}
>
مشاهدهٔ نوبت
</Link>
) : (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title={ledger ? `دفتر اعتبار: ${ledger.package.package_name}` : 'دفتر اعتبار'}
description="هر تغییر اعتبار یک ردیف است؛ ردیف‌ها حذف یا ویرایش نمی‌شوند."
backTo="/admin/patients"
/>
{ledger && (
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14 }}>
مانده: <strong>{ledger.package.balance}</strong> از {ledger.package.session_count} جلسه
</span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
پرداختی: {formatRial(ledger.package.price_paid_rials)}
</span>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
خرید: {formatDate(ledger.package.purchased_at)}
{ledger.package.valid_to !== null && ` · اعتبار تا ${formatDate(ledger.package.valid_to)}`}
</span>
{ledger.package.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
{canCorrect && (
<div style={{ display: 'flex', gap: 8, marginRight: 'auto' }}>
<button type="button" className="btn secondary sm" onClick={() => setAdjusting(true)}>
اصلاح دستی
</button>
<button
type="button"
className="btn secondary sm"
disabled={ledger.package.balance <= 0 || expire.isPending}
onClick={() => expire.mutate('ابطال دستی توسط کلینیک')}
>
ابطال مانده
</button>
</div>
)}
</div>
)}
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={ledger?.rows ?? []}
loading={loading}
emptyMessage="این پکیج هنوز تراکنشی ندارد"
/>
</div>
<Modal
open={adjusting}
title="اصلاح دستی اعتبار"
onClose={() => setAdjusting(false)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={delta === 0 || reason.trim() === '' || adjust.isPending}
onClick={async () => {
await adjust.mutateAsync({ delta, reason: reason.trim() });
setAdjusting(false);
setReason('');
}}
>
ثبت اصلاح
</button>
<button type="button" className="btn secondary" onClick={() => setAdjusting(false)}>
انصراف
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field">
<label htmlFor="adj-delta">تغییر (مثبت یا منفی)</label>
<input
id="adj-delta"
className="input"
type="number"
value={delta}
onChange={(e) => setDelta(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="adj-reason">دلیل</label>
<input
id="adj-reason"
className="input"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً: جبران جلسهٔ لغوشده توسط کلینیک"
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
دلیل در دفتر ثبت میشود و بعداً قابل ویرایش نیست.
</span>
</div>
</div>
</Modal>
</div>
);
}
@@ -1,63 +0,0 @@
import { describe, it, expect, beforeEach, vi } 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() } }));
import { api } from '../lib/api';
import PlanAccuracyPage from './PlanAccuracyPage';
const get = api.get as ReturnType<typeof vi.fn>;
const rows = [
{
service_uuid: 's1',
service_name: 'لیزر فول‌بادی',
sample_size: 4,
planned_minutes: 60,
actual_minutes: 90,
deviation_percent: 50,
severity: 'high' as const,
},
{
service_uuid: 's2',
service_name: 'مشاوره',
sample_size: 5,
planned_minutes: 30,
actual_minutes: 29,
deviation_percent: -3,
severity: 'none' as const,
},
];
describe('PlanAccuracyPage', () => {
beforeEach(() => {
vi.clearAllMocks();
get.mockResolvedValue({ success: true, data: { from: 1, to: 2, rows } });
});
it('shows planned against actual with a signed deviation', async () => {
renderWithProviders(<PlanAccuracyPage />, { route: '/admin/reports/plan-accuracy' });
await waitFor(() => expect(screen.getByText('لیزر فول‌بادی')).toBeInTheDocument());
expect(screen.getByText('+50٪')).toBeInTheDocument();
expect(screen.getByText('-3٪')).toBeInTheDocument();
expect(screen.getByText('زیاد')).toBeInTheDocument();
expect(screen.getByText('دقیق')).toBeInTheDocument();
});
/** نمونهٔ کوچک از گزارش حذف می‌شود؛ صفحه باید همان قاعده را بگوید. */
it('explains that small samples are excluded', async () => {
renderWithProviders(<PlanAccuracyPage />, { route: '/admin/reports/plan-accuracy' });
await waitFor(() =>
expect(screen.getByText(/کمتر از سه نوبت انجام‌شده در گزارش نمی‌آیند/)).toBeInTheDocument(),
);
});
});
-130
View File
@@ -1,130 +0,0 @@
import React, { useMemo } from 'react';
import { useUrlState } from '../hooks/useUrlState';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import SearchableSelect from '../components/ui/SearchableSelect';
import { Link } from 'react-router-dom';
import { usePlanAccuracy } from '../hooks/useReports';
import type { AccuracyRow } from '../types';
const RANGES = [
{ value: '30', label: 'ماه گذشته' },
{ value: '90', label: 'سه ماه گذشته' },
];
const SEVERITY: Record<string, { label: string; className: string }> = {
none: { label: 'دقیق', className: 'badge green' },
low: { label: 'کم', className: 'badge' },
medium: { label: 'متوسط', className: 'badge amber' },
high: { label: 'زیاد', className: 'badge red' },
// زیر آستانهٔ نمونه: عدد هست ولی قابل استناد نیست. ادعای «دقیق» اینجا دروغ است.
unrated: { label: 'نمونهٔ کم', className: 'badge' },
};
/**
* مدت پیش‌بینی‌شده در برابر مدت واقعی.
*
* سرویسی که یک ساعت پیش‌بینی شده ولی یک‌ساعت‌ونیم طول می‌کشد، هر روز نیم ساعت از ظرفیت
* کلینیک را بی‌صدا می‌خورد — این صفحه تنها جایی است که آن را نشان می‌دهد.
*/
export default function PlanAccuracyPage() {
// بازه در URL: لینکِ گزارش باید همان بازه‌ای را باز کند که فرستنده دیده بود.
const [urlState, setUrlState] = useUrlState({ days: '30' });
const days = urlState.days;
const setDays = (v: string) => setUrlState({ days: v });
const range = useMemo(() => {
const to = Math.floor(Date.now() / 1000);
return { from: to - Number(days) * 86400, to };
}, [days]);
const { rows, loading } = usePlanAccuracy(range.from, range.to);
const columns: Column<AccuracyRow>[] = [
{
key: 'service_name',
header: 'خدمت',
render: (r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{r.service_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.sample_size} نوبت</span>
</div>
),
},
{
key: 'planned_minutes',
header: 'پیش‌بینی',
render: (r) => <span style={{ fontSize: 13 }}>{r.planned_minutes} دقیقه</span>,
},
{
key: 'actual_minutes',
header: 'واقعی',
render: (r) => <span style={{ fontSize: 13 }}>{r.actual_minutes} دقیقه</span>,
},
{
key: 'deviation_percent',
header: 'انحراف',
render: (r) => (
<span
style={{
fontSize: 13,
fontWeight: 600,
color: r.severity === 'high' ? 'var(--danger)' : r.severity === 'medium' ? 'var(--warning)' : undefined,
opacity: r.below_min_sample ? 0.55 : 1,
}}
>
{r.deviation_percent > 0 ? `+${r.deviation_percent}` : r.deviation_percent}٪
</span>
),
},
{
key: 'fix',
header: '',
// گزارشی که راه اصلاح ندهد خوانده نمی‌شود.
render: (r) => (
<Link className="btn secondary sm" to={`/admin/services?service=${r.service_uuid}`}>
ویرایش بخشهای این خدمت
</Link>
),
},
{
key: 'severity',
header: 'شدت',
render: (r) => (
<span className={SEVERITY[r.severity ?? 'unrated'].className}>
<span className="bdot" />
{SEVERITY[r.severity ?? 'unrated'].label}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title="دقت برنامهٔ نوبت"
description="مقایسهٔ مدت پیش‌بینی‌شدهٔ هر خدمت با مدت واقعی نوبت‌های انجام‌شده."
backTo="/admin/settings-menu"
/>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 200, margin: 0 }}>
<label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '30'))} options={RANGES} />
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)', alignSelf: 'flex-end' }}>
خدماتی با کمتر از سه نوبت انجامشده در گزارش نمیآیند.
</span>
</div>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
emptyMessage="داده‌ای برای این بازه نیست"
/>
</div>
</div>
);
}
-147
View File
@@ -1,147 +0,0 @@
import React, { useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { BeakerIcon, PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import { ActiveBadge } from '../components/ui/StatusBadge';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { usePolicies, usePolicySchema } from '../hooks/usePolicies';
import type { Policy, PolicyCategory } from '../types';
/**
* فهرست قوانین محیط.
*
* دکمهٔ «فعال‌سازی» عمداً اینجا نیست: فعال‌سازی بدون دیدن نتیجهٔ آزمایش همان چیزی است
* که این تسک آمده جلویش را بگیرد. مسیر از این صفحه به صفحهٔ آزمایش می‌رود.
*/
export default function PoliciesPage() {
const navigate = useNavigate();
const { schema } = usePolicySchema();
const [urlState, setUrlState] = useUrlState({ search: '', category: '' });
const category = urlState.category as PolicyCategory | '';
const { policies, loading, deactivate } = usePolicies(category);
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const categoryOptions = useMemo(
() =>
Object.entries(schema ?? {}).map(([key, value]) => ({
value: key,
label: value.label,
})),
[schema],
);
const rows = useMemo(() => {
const q = urlState.search.trim();
return policies.filter((p) => q === '' || p.name.includes(q));
}, [policies, urlState.search]);
const columns: Column<Policy>[] = [
{
key: 'name',
header: 'قانون',
render: (p) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{p.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{schema?.[p.category]?.label ?? p.category} · نسخهٔ {p.version}
</span>
</div>
),
},
{
key: 'effects',
header: 'اثر',
render: (p) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{p.effects
.map(
(e) =>
schema?.[p.category]?.effects.find((m) => m.type === e.type)?.label ?? e.type,
)
.join('، ') || '—'}
</span>
),
},
{
key: 'scope',
header: 'دامنه',
render: (p) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.address_uuid ? 'شعبه' : p.service_uuid ? 'سرویس' : p.catalog_category_uuid ? 'دسته' : 'کل محیط'}
</span>
),
},
{
key: 'priority',
header: 'اولویت',
render: (p) => <span style={{ fontSize: 13 }}>{p.priority}</span>,
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
return (
<div className="fade-in">
<PageHeader
title="قوانین"
description="قانون‌ها رفتار نوبت‌دهی را بدون تغییر کد عوض می‌کنند. هر قانون پیش از فعال شدن باید آزمایش شود."
backTo="/admin/settings-menu"
action={
canManage ? (
<Link className="btn primary sm" to="/admin/policies/new">
<PlusIcon style={{ width: 15 }} /> قانون تازه
</Link>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در قوانین..."
emptyMessage="هنوز قانونی تعریف نشده است"
headerExtra={
<div style={{ minWidth: 200, marginRight: 'auto' }}>
<SearchableSelect
value={urlState.category}
onChange={(v) => setUrlState({ category: String(v ?? '') })}
options={[{ value: '', label: 'همهٔ دسته‌ها' }, ...categoryOptions]}
placeholder="دسته"
/>
</div>
}
actions={(p) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() => navigate(`/admin/policies/${p.uuid}/simulate`)}
>
<BeakerIcon style={{ width: 15 }} /> آزمایش
</button>
{canManage && p.active && (
<button
type="button"
className="btn secondary sm"
disabled={deactivate.isPending}
onClick={() => deactivate.mutate(p.uuid)}
>
غیرفعال
</button>
)}
</div>
)}
/>
</div>
);
}
@@ -1,84 +0,0 @@
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 PolicyFormPage from './PolicyFormPage';
const get = api.get as ReturnType<typeof vi.fn>;
/**
* فیلدِ ساختگی: در هیچ کجای فرانت نوشته نشده. اگر در صفحه ظاهر شود، یعنی فرم واقعاً
* از schema ساخته می‌شود و فیلد تازهٔ بک‌اند بدون تغییر فرانت کار می‌کند.
*/
const schema = {
timing: {
label: 'مدت نوبت',
fields: ['made_up_field', 'patient_tags'],
operators: ['equals', 'not_equals', 'greater_than', 'less_than', 'in', 'contains'],
field_meta: [
{
key: 'made_up_field',
label: 'فیلد ساختگی آزمون',
type: 'int',
operators: ['equals', 'greater_than'],
},
{ key: 'patient_tags', label: 'برچسب‌های بیمار', type: 'list', operators: ['contains'] },
],
effects: [
{ type: 'min_duration_minutes', label: 'حداقل مدت (دقیقه)', value_type: 'int', combination: 'max' },
],
},
};
function mockApi() {
get.mockImplementation((path: string) => {
if (path === '/api/v1/policy-schema') return Promise.resolve({ success: true, data: schema });
if (path === '/api/v1/policy-templates') return Promise.resolve({ success: true, data: [] });
return Promise.resolve({ success: true, data: null });
});
}
async function openAdvanced() {
renderWithProviders(<PolicyFormPage />, { route: '/admin/policies/new' });
fireEvent.click(screen.getByText('پیشرفته'));
await waitFor(() => expect(screen.getByText('افزودن شرط')).toBeInTheDocument());
fireEvent.click(screen.getByText('افزودن شرط'));
}
describe('PolicyFormPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockApi();
});
it('renders a field that exists only in the schema, with no frontend change', async () => {
await openAdvanced();
await waitFor(() => expect(screen.getByText('فیلد ساختگی آزمون')).toBeInTheDocument());
});
it('offers only the operators that make sense for the field type', async () => {
await openAdvanced();
await waitFor(() => expect(screen.getByText('برابر است با')).toBeInTheDocument());
// فیلد از نوع `int` است، پس «شامل» — که فقط برای فهرست معنا دارد — نباید باشد.
expect(screen.queryByText('شامل')).not.toBeInTheDocument();
});
it('renders the effects of the selected category from the schema', async () => {
renderWithProviders(<PolicyFormPage />, { route: '/admin/policies/new' });
fireEvent.click(screen.getByText('پیشرفته'));
await waitFor(() => expect(screen.getByText('حداقل مدت (دقیقه)')).toBeInTheDocument());
expect(screen.getByText(/ترکیب: max/)).toBeInTheDocument();
});
});
-280
View File
@@ -1,280 +0,0 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
import PolicyConditionBuilder from '../components/PolicyConditionBuilder';
import {
usePolicyMutations,
usePolicySchema,
usePolicyTemplates,
} from '../hooks/usePolicies';
import type { PolicyCategory, PolicyClause, PolicyEffect } from '../types';
type Mode = 'template' | 'advanced';
/**
* ساخت قانون — با الگو (راه ۹۰٪ کاربران) یا دستی.
*
* قانون تازه پیش‌نویس ذخیره می‌شود و کاربر مستقیم به صفحهٔ آزمایش می‌رود: فعال‌سازی
* بدون دیدن نتیجه ممکن نیست، پس بردنش به همان‌جا کوتاه‌ترین مسیر درست است.
*/
export default function PolicyFormPage() {
const navigate = useNavigate();
const { schema } = usePolicySchema();
const { templates } = usePolicyTemplates();
const { create } = usePolicyMutations();
const [mode, setMode] = useState<Mode>('template');
const [name, setName] = useState('');
const [priority, setPriority] = useState(0);
const [templateKey, setTemplateKey] = useState('');
const [values, setValues] = useState<Record<string, string>>({});
const [category, setCategory] = useState<PolicyCategory>('timing');
const [match, setMatch] = useState<'all' | 'any'>('all');
const [clauses, setClauses] = useState<PolicyClause[]>([]);
const [effects, setEffects] = useState<PolicyEffect[]>([]);
const template = templates.find((t) => t.key === templateKey);
const categorySchema = schema?.[category];
const categoryOptions = useMemo(
() => Object.entries(schema ?? {}).map(([key, v]) => ({ value: key, label: v.label })),
[schema],
);
const submit = async () => {
const body =
mode === 'template'
? { name, priority, template: templateKey, values }
: {
name,
priority,
category,
condition: clauses.length ? { match, conditions: clauses } : {},
effects,
};
const created = await create.mutateAsync(body);
navigate(`/admin/policies/${created.data.uuid}/simulate`);
};
const canSubmit =
name.trim() !== '' &&
(mode === 'template' ? templateKey !== '' : effects.length > 0);
return (
<div className="fade-in">
<PageHeader
title="قانون تازه"
description="قانون پیش‌نویس ذخیره می‌شود؛ برای فعال شدن باید یک بار آزمایش شود."
backTo="/admin/policies"
/>
<div className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className={`btn sm ${mode === 'template' ? 'primary' : 'secondary'}`}
onClick={() => setMode('template')}
>
از الگو
</button>
<button
type="button"
className={`btn sm ${mode === 'advanced' ? 'primary' : 'secondary'}`}
onClick={() => setMode('advanced')}
>
پیشرفته
</button>
</div>
<div className="field-block">
<label htmlFor="policy-name">نام قانون</label>
<input
id="policy-name"
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً: حداقل ۲۱ روز فاصله بین جلسات لیزر"
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
این نام در پیام خطا به بیمار نشان داده میشود؛ قابل فهم بنویسید.
</span>
</div>
<div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="policy-priority">اولویت</label>
<input
id="policy-priority"
className="input"
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
در تناقض، عدد بزرگتر برنده است.
</span>
</div>
{mode === 'template' ? (
<>
<div className="field-block">
<label>الگو</label>
<SearchableSelect
value={templateKey}
onChange={(v) => {
setTemplateKey(String(v ?? ''));
setValues({});
}}
options={templates.map((t) => ({ value: t.key, label: t.title }))}
placeholder="یک الگو انتخاب کنید"
/>
{template && (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{template.description}</span>
)}
</div>
{template?.inputs.map((input) => (
<div className="field-block" key={input.key} style={{ maxWidth: 260 }}>
<label htmlFor={`tpl-${input.key}`}>{input.label}</label>
<input
id={`tpl-${input.key}`}
className="input"
type={input.type === 'int' ? 'number' : 'text'}
min={input.min}
max={input.max}
value={values[input.key] ?? ''}
onChange={(e) => setValues({ ...values, [input.key]: e.target.value })}
/>
</div>
))}
</>
) : (
<>
<div className="field-block" style={{ maxWidth: 260 }}>
<label>دسته</label>
<SearchableSelect
value={category}
onChange={(v) => {
setCategory((v as PolicyCategory) ?? 'timing');
// فیلدها و اثرهای مجاز per دسته فرق دارند؛ نگه‌داشتنشان یعنی ۴۲۲.
setClauses([]);
setEffects([]);
}}
options={categoryOptions}
/>
</div>
{categorySchema && (
<>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>شرط</h3>
<PolicyConditionBuilder
schema={categorySchema}
match={match}
clauses={clauses}
onChange={(m, c) => {
setMatch(m);
setClauses(c);
}}
/>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>اثر</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{categorySchema.effects.map((meta) => {
const current = effects.find((e) => e.type === meta.type);
return (
<div
key={meta.type}
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 200 }}>
<input
type="checkbox"
checked={!!current}
onChange={(e) =>
setEffects(
e.target.checked
? [...effects, { type: meta.type, value: meta.value_type === 'int' ? 0 : '' }]
: effects.filter((x) => x.type !== meta.type),
)
}
/>
{meta.label}
</label>
{current && meta.value_type !== 'none' && (
<input
className="input"
style={{ maxWidth: 180 }}
type={meta.value_type === 'int' ? 'number' : 'text'}
value={String(current.value ?? '')}
onChange={(e) =>
setEffects(
effects.map((x) =>
x.type === meta.type
? {
...x,
value:
meta.value_type === 'int'
? Number(e.target.value)
: e.target.value,
}
: x,
),
)
}
/>
)}
{current && meta.type === 'forbid' && (
<input
className="input"
style={{ flex: 1 }}
value={String(current.reason ?? '')}
placeholder="پیامی که بیمار می‌بیند"
onChange={(e) =>
setEffects(
effects.map((x) =>
x.type === 'forbid' ? { ...x, reason: e.target.value } : x,
),
)
}
/>
)}
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
ترکیب: {meta.combination}
</span>
</div>
);
})}
</div>
</div>
</>
)}
</>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn primary"
disabled={!canSubmit || create.isPending}
onClick={submit}
>
ذخیره و آزمایش
</button>
<button type="button" className="btn secondary" onClick={() => navigate('/admin/policies')}>
انصراف
</button>
</div>
</div>
</div>
);
}
@@ -1,104 +0,0 @@
import { describe, it, expect, beforeEach, vi } 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() } }));
import { Routes, Route } from 'react-router-dom';
import { api } from '../lib/api';
import PolicySimulationPage from './PolicySimulationPage';
const get = api.get as ReturnType<typeof vi.fn>;
const policy = {
uuid: 'p1',
category: 'pricing',
name: 'تخفیف وفاداری',
condition: {},
effects: [{ type: 'discount_percent', value: 25 }],
priority: 0,
version: 2,
active: false,
valid_from: null,
valid_to: null,
address_uuid: null,
service_uuid: null,
catalog_category_uuid: null,
specificity: 0,
};
function run(version: number, severity: string) {
return {
uuid: 'r1',
policy_uuid: 'p1',
policy_version: version,
sample_size: 4,
affected_count: 3,
affected_percent: 75,
severity,
created_at: 1_700_000_000,
warning: null,
rows: [
{
appointment_uuid: 'a1',
patient_name: 'ز. احمدی',
slot_start: 1_700_000_000,
before: '2,000,000 ریال',
after: '1,500,000 ریال',
reason: '500,000 ریال تخفیف',
},
],
};
}
function mockApi(runs: unknown[]) {
get.mockImplementation((path: string) => {
if (path === '/api/v1/policy/p1') return Promise.resolve({ success: true, data: policy });
if (path === '/api/v1/policy/p1/simulations') return Promise.resolve({ success: true, data: runs });
return Promise.resolve({ success: true, data: null });
});
}
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/policies/:policyUuid/simulate" element={<PolicySimulationPage />} />
</Routes>,
{ route: '/admin/policies/p1/simulate' },
);
}
describe('PolicySimulationPage', () => {
beforeEach(() => vi.clearAllMocks());
it('shows the before/after change of each affected appointment', async () => {
mockApi([run(2, 'high')]);
renderPage();
await waitFor(() => expect(screen.getByText('ز. احمدی')).toBeInTheDocument());
expect(screen.getByText('2,000,000 ریال')).toBeInTheDocument();
expect(screen.getByText('1,500,000 ریال')).toBeInTheDocument();
expect(screen.getByText(/تحت تأثیر: 3 نوبت/)).toBeInTheDocument();
});
/** گزارشِ نسخهٔ قبلی نباید دکمهٔ فعال‌سازی را باز کند — همان قاعدهٔ بک‌اند. */
it('disables activation when the report belongs to an older version', async () => {
mockApi([run(1, 'low')]);
renderPage();
await waitFor(() => expect(screen.getByText(/فعال‌سازی قانون/)).toBeDisabled());
expect(screen.getByText(/دوباره آزمایش کنید/)).toBeInTheDocument();
});
it('enables activation when the report matches the current version', async () => {
mockApi([run(2, 'low')]);
renderPage();
await waitFor(() => expect(screen.getByText(/فعال‌سازی قانون/)).toBeEnabled());
});
});
-184
View File
@@ -1,184 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { BeakerIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DataTable, { type Column } from '../components/ui/DataTable';
import PolicyVersionDiff from '../components/PolicyVersionDiff';
import { formatDate } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import { usePolicies, usePolicy, usePolicySimulation } from '../hooks/usePolicies';
import type { PolicySimulationRun, SimulationRow } from '../types';
const SEVERITY: Record<PolicySimulationRun['severity'], { label: string; className: string; note: string }> = {
none: {
label: 'بدون اثر',
className: 'badge',
note: 'این قانون روی هیچ نوبتی از نمونه اثر نداشت — احتمالاً شرطش هرگز برقرار نمی‌شود.',
},
low: { label: 'کم', className: 'badge green', note: 'اثر محدود و قابل انتظار.' },
medium: { label: 'متوسط', className: 'badge amber', note: 'بخش قابل‌توجهی از نوبت‌ها تغییر می‌کنند.' },
high: {
label: 'زیاد',
className: 'badge red',
note: 'بیشتر نوبت‌های نمونه تغییر می‌کنند. مطمئنید قانون درست نوشته شده؟',
},
};
/**
* آزمایش قانون روی نوبت‌های واقعی گذشته.
*
* ستون «وضعیت فعلی → با این قانون» تنها چیزی است که کاربر غیرفنی می‌فهمد؛ درصد و شدت
* هم لازم‌اند چون قانونی که ۹۸٪ نوبت‌ها را عوض می‌کند تقریباً همیشه اشتباه است.
*/
export default function PolicySimulationPage() {
const { policyUuid } = useParams<{ policyUuid: string }>();
const navigate = useNavigate();
const { policy } = usePolicy(policyUuid);
const { runs, loading, run } = usePolicySimulation(policyUuid);
const { activate } = usePolicies();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [latest, setLatest] = useState<PolicySimulationRun | null>(null);
const [confirming, setConfirming] = useState(false);
const current = latest ?? runs[0] ?? null;
const isCurrentVersion = !!policy && !!current && current.policy_version === policy.version;
const severity = current ? SEVERITY[current.severity] : null;
const columns: Column<SimulationRow>[] = [
{
key: 'patient_name',
header: 'بیمار',
render: (r) => <span style={{ fontSize: 13 }}>{r.patient_name}</span>,
},
{
key: 'slot_start',
header: 'تاریخ نوبت',
render: (r) => <span style={{ fontSize: 13 }}>{formatDate(r.slot_start)}</span>,
},
{
key: 'change',
header: 'وضعیت فعلی ← با این قانون',
render: (r) => (
<span style={{ fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>{r.before}</span>
{' ← '}
<span style={{ fontWeight: 600 }}>{r.after}</span>
</span>
),
},
{
key: 'reason',
header: 'توضیح',
render: (r) => <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{r.reason}</span>,
},
];
return (
<div className="fade-in">
<PageHeader
title={policy ? `آزمایش قانون: ${policy.name}` : 'آزمایش قانون'}
description="اجرای قانون روی نوبت‌های واقعی گذشته. هیچ چیزی ثبت یا تغییر نمی‌شود."
backTo="/admin/policies"
action={
<button
type="button"
className="btn primary sm"
disabled={run.isPending}
onClick={async () => setLatest((await run.mutateAsync(50)).data)}
>
<BeakerIcon style={{ width: 15 }} /> اجرای آزمایش
</button>
}
/>
{current && severity && (
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14 }}>
نمونه: {current.sample_size} نوبت · تحت تأثیر: {current.affected_count} نوبت (
{current.affected_percent}٪)
</span>
<span className={severity.className}>
<span className="bdot" /> شدت: {severity.label}
</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
نسخهٔ آزمایششده: {current.policy_version}
</span>
</div>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-2)' }}>
{current.warning ?? severity.note}
</p>
{!isCurrentVersion && (
<p style={{ margin: 0, fontSize: 13, color: 'var(--warning)' }}>
این گزارش برای نسخهٔ {current.policy_version} است و قانون اکنون نسخهٔ{' '}
{policy?.version} است. برای فعالسازی، دوباره آزمایش کنید.
</p>
)}
{canManage && (
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
type="button"
className="btn primary sm"
disabled={!isCurrentVersion || activate.isPending || policy?.active}
onClick={() => {
// شدت زیاد یعنی احتمالاً قانون اشتباه نوشته شده؛ یک قدم مکث لازم است.
if (current.severity === 'high') {
setConfirming(true);
return;
}
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
}}
>
{policy?.active ? 'قانون فعال است' : 'فعال‌سازی قانون'}
</button>
<button
type="button"
className="btn secondary sm"
onClick={() => navigate('/admin/policies')}
>
بازگشت به فهرست
</button>
</div>
)}
</div>
)}
<ConfirmDialog
open={confirming}
title="فعال‌سازی قانون پرتأثیر"
message={`این قانون ${current?.affected_percent ?? 0}٪ نوبت‌های نمونه را تغییر می‌دهد. مطمئنید می‌خواهید فعالش کنید؟`}
confirmLabel="بله، فعال کن"
danger
loading={activate.isPending}
onCancel={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
}}
/>
{policy?.versions && policy.versions.length > 1 && (
<PolicyVersionDiff versions={policy.versions as never} />
)}
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={current?.rows ?? []}
loading={loading || run.isPending}
emptyMessage={
current
? 'هیچ نوبتی از نمونه با این قانون تغییر نمی‌کرد'
: 'برای دیدن اثر این قانون، یک آزمایش اجرا کنید'
}
/>
</div>
</div>
);
}
@@ -1,189 +0,0 @@
import React, { useMemo } from 'react';
import { useUrlState } from '../hooks/useUrlState';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import SearchableSelect from '../components/ui/SearchableSelect';
import PersianDateInput from '../components/ui/PersianDateInput';
import ResourceUtilizationChart from '../components/ResourceUtilizationChart';
import { Link } from 'react-router-dom';
import { isoToUnix, unixToIso } from '../lib/utils';
import { useBranches } from '../hooks/useBranches';
import { useResourceUtilization } from '../hooks/useReports';
import type { UtilizationRow } from '../types';
const RANGES = [
{ value: '7', label: 'هفتهٔ گذشته' },
{ value: '30', label: 'ماه گذشته' },
{ value: '90', label: 'سه ماه گذشته' },
{ value: 'custom', label: 'بازهٔ دلخواه' },
];
function percent(value: number | null): string {
return value === null ? '—' : `${Math.round(value * 100)}٪`;
}
/**
* بهره‌وری منابع.
*
* ستون «نسبت کار مفید» مهم‌ترین ستون است: فاصله‌اش با «اشغال» همان چیزی است که تعریف
* غلط بخش‌ها را لو می‌دهد.
*/
export default function ResourceUtilizationPage() {
const { branches } = useBranches();
// بازه و شعبه در URL می‌نشینند نه در state: بازگشت از صفحهٔ منبع باید همان گزارش را
// برگرداند، و لینکِ گزارش باید همان چیزی را نشان بدهد که فرستنده دیده.
const [urlState, setUrlState] = useUrlState({ branch: '', days: '7', from: '', to: '' });
const branchUuid = urlState.branch;
const days = urlState.days;
const setBranchUuid = (v: string) => setUrlState({ branch: v });
const setDays = (v: string) => setUrlState({ days: v });
/**
* بازهٔ آماده برای حالت عادی، بازهٔ دلخواه برای وقتی که کاربر دقیقاً می‌داند چه
* می‌خواهد — مثلاً مقایسهٔ دو ماه مشخص با هم.
*/
const range = useMemo(() => {
if (days === 'custom') {
return {
from: isoToUnix(urlState.from) ?? Math.floor(Date.now() / 1000) - 7 * 86400,
to: isoToUnix(urlState.to) ?? Math.floor(Date.now() / 1000),
};
}
const to = Math.floor(Date.now() / 1000);
return { from: to - Number(days) * 86400, to };
}, [days, urlState.from, urlState.to]);
const { rows, loading } = useResourceUtilization(branchUuid || undefined, range.from, range.to);
const columns: Column<UtilizationRow>[] = [
{
key: 'resource_name',
header: 'منبع',
render: (r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{r.resource_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.role}</span>
</div>
),
},
{
key: 'available_minutes',
header: 'در دسترس',
render: (r) => <span style={{ fontSize: 13 }}>{r.available_minutes} دقیقه</span>,
},
{
key: 'occupied_minutes',
header: 'اشغال',
render: (r) => <span style={{ fontSize: 13 }}>{r.occupied_minutes} دقیقه</span>,
},
{
key: 'active_minutes',
header: 'کار مفید',
render: (r) => <span style={{ fontSize: 13 }}>{r.active_minutes} دقیقه</span>,
},
{
key: 'utilization',
header: 'بهره‌وری',
// `null` یعنی «تعریف‌نشده» نه «صفر»، و کار بعدی روشن است: تقویم منبع را بساز.
// بدون لینک، کاربر عدد را می‌بیند و نمی‌داند کجا باید برود.
render: (r) =>
r.utilization === null ? (
<Link
to={`/admin/resources/${r.resource_uuid}/calendar`}
style={{ fontSize: 12, color: 'var(--primary)' }}
title="برای این منبع تقویمی تعریف نشده است"
>
تنظیم تقویم
</Link>
) : (
<span style={{ fontSize: 13 }}>{percent(r.utilization)}</span>
),
},
{
key: 'active_ratio',
header: 'نسبت کار مفید',
// توضیح باید در خودِ صفحه باشد، نه فقط در مستندات: کسی که گزارش را می‌خواند
// مستندات را باز نمی‌کند.
render: (r) => (
<span
style={{ fontSize: 13, fontWeight: 600, color: r.wasted_capacity ? 'var(--danger)' : undefined }}
title="چه سهمی از زمانِ اشغال، واقعاً کار روی بیمار بوده"
>
{percent(r.active_ratio)}
</span>
),
},
{
key: 'wasted_capacity',
header: '',
render: (r) =>
r.wasted_capacity ? (
<span className="badge red"><span className="bdot" />ظرفیت هدررفته</span>
) : null,
},
];
return (
<div className="fade-in">
<PageHeader
title="بهره‌وری منابع"
description="چقدر از ظرفیت هر منبع در بازهٔ انتخاب‌شده واقعاً استفاده شده است."
backTo="/admin/settings-menu"
/>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه</label>
<SearchableSelect
value={branchUuid}
onChange={(v) => setBranchUuid(String(v ?? ''))}
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
<div className="field-block" style={{ minWidth: 180, margin: 0 }}>
<label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
</div>
{days === 'custom' && (
<>
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
<label>از تاریخ</label>
<PersianDateInput
value={urlState.from || unixToIso(range.from)}
onChange={(v) => setUrlState({ from: v })}
/>
</div>
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
<label>تا تاریخ</label>
<PersianDateInput
value={urlState.to || unixToIso(range.to)}
onChange={(v) => setUrlState({ to: v })}
/>
</div>
</>
)}
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
«اشغال» شامل آمادهسازی، تمیزکاری و بخشهای انتظار است؛ «کار مفید» فقط زمانی که
بیمار حاضر بوده. فاصلهٔ این دو نشان میدهد بخشهای نوبت درست تعریف شدهاند یا نه.
</p>
<ResourceUtilizationChart rows={rows} />
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
emptyMessage={branchUuid === '' ? 'برای دیدن گزارش، شعبه را انتخاب کنید' : 'منبعی برای این شعبه نیست'}
/>
</div>
</div>
);
}
@@ -1,93 +0,0 @@
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();
});
});
-343
View File
@@ -1,343 +0,0 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
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, formatNumber } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches';
import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses';
import { useQuery } from '@tanstack/react-query';
import { api, type ApiResponse } from '../lib/api';
import type { CourseSessionRow } from '../types';
const SESSION_STATUS: Record<CourseSessionRow['status'], { label: string; className: string }> = {
planned: { label: 'برنامه‌ریزی‌شده', className: 'badge' },
booked: { label: 'رزروشده', className: 'badge amber' },
completed: { label: 'انجام‌شده', className: 'badge green' },
skipped: { label: 'رد‌شده', className: 'badge red' },
};
/**
* یک دورهٔ درمان: پیشرفت، جلسات، و پیشنهاد تاریخ جلسهٔ بعدی.
*
* پیشنهاد به شعبه وابسته است (ظرفیت هر شعبه فرق دارد)، پس تا شعبه انتخاب نشود چیزی
* پرسیده نمی‌شود.
*/
export default function TreatmentCoursePage() {
const { courseUuid } = useParams<{ courseUuid: string }>();
const { course, loading, abandon, bookAll } = useTreatmentCourse(courseUuid);
const { branches } = useBranches();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [branchUuid, setBranchUuid] = useState('');
const [doctorUuid, setDoctorUuid] = useState('');
const [abandoning, setAbandoning] = useState(false);
const [reason, setReason] = useState('');
const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined);
/** همان اندپوینتی که صفحهٔ رزرو منبع‌محور استفاده می‌کند — منشی فقط پزشکان خودش را می‌بیند. */
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
queryKey: ['booking-doctors'],
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
staleTime: 60_000,
});
const doctors = doctorsQuery.data?.data?.data ?? [];
/**
* دوره‌ای که وسطش لغو شده، بی‌صدا کِش می‌آید: جلسه به «برنامه‌ریزی‌شده» برمی‌گردد و
* هیچ‌کس خبردار نمی‌شود.
*
* هشدارِ پیشنهاد فقط وقتی می‌آید که شعبه انتخاب شده باشد؛ این یکی از خودِ دوره حساب
* می‌شود، پس بلافاصله دیده می‌شود. مبنا آخرین جلسهٔ **دارای تاریخ** است — همان لنگری
* که پروتکل با آن فاصله می‌سنجد.
*/
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',
header: 'جلسه',
render: (s) => <span style={{ fontWeight: 600 }}>{s.session_number}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: (s) => (
<span className={SESSION_STATUS[s.status].className}>
<span className="bdot" />
{SESSION_STATUS[s.status].label}
</span>
),
},
{
key: 'slot_start',
header: 'تاریخ نوبت',
render: (s) => (
<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: 'پارامتر',
render: (s) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{Object.entries(s.params).length === 0
? '—'
: Object.entries(s.params)
.map(([k, v]) => `${k}: ${v}`)
.join('، ')}
</span>
),
},
{
key: 'completed_at',
header: 'انجام‌شده در',
render: (s) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{s.completed_at === null ? '—' : formatDate(s.completed_at)}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title={course ? `دورهٔ ${course.service_name}` : 'دورهٔ درمان'}
description="پیشرفت دوره، جلسات و پیشنهاد تاریخ جلسهٔ بعدی."
backTo="/admin/patients"
/>
{course && (
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<span style={{ fontSize: 15 }}>
جلسهٔ <strong>{course.progress.completed}</strong> از {course.progress.total} انجام شده
</span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
رزروشده: {course.progress.booked} · باقیمانده: {course.progress.planned}
</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
فاصله: حداقل {course.min_days} · ایدهآل {course.ideal_days} · حداکثر {course.max_days} روز
</span>
{course.status !== 'active' && (
<span className="badge red">
<span className="bdot" />
{course.status === 'completed' ? 'تمام‌شده' : 'رهاشده'}
</span>
)}
</div>
{/* پکیجی که کفاف جلسات باقی‌مانده را نمی‌دهد، دوره را باطل نمی‌کند — بقیه‌اش
نقدی می‌شود. ولی باید قبل از جلسهٔ ششم دانسته شود، نه سرِ آن. */}
{(course.package_shortfall ?? 0) > 0 && (
<span
style={{
fontSize: 13,
lineHeight: 1.8,
color: 'var(--warning)',
background: 'var(--warning-bg)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
اعتبار پکیج برای {formatNumber(course.package_shortfall ?? 0)} جلسه کم میآید
(مانده: {formatNumber(course.package_balance ?? 0)}). بقیهٔ جلسات با قیمت
عادی حساب میشوند.
</span>
)}
{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>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه برای پیشنهاد وقت</label>
<SearchableSelect
value={branchUuid}
onChange={(v) => setBranchUuid(String(v ?? ''))}
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
{/* رزرو گروهی پزشک لازم دارد؛ خودِ دوره پزشکی ندارد چون هر جلسه می‌تواند
با پزشک دیگری باشد. */}
{canManage && course.status === 'active' && (
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>پزشک برای رزرو گروهی</label>
<SearchableSelect
value={doctorUuid}
onChange={(v) => setDoctorUuid(String(v ?? ''))}
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
placeholder="انتخاب پزشک"
/>
</div>
)}
{canManage && course.status === 'active' && (
<button
type="button"
className="btn primary sm"
disabled={branchUuid === '' || doctorUuid === '' || bookAll.isPending}
title={
branchUuid === '' || doctorUuid === ''
? 'شعبه و پزشک را انتخاب کنید'
: 'همهٔ جلسات برنامه‌ریزی‌شده یکجا رزرو می‌شوند — همه یا هیچ'
}
onClick={() => bookAll.mutate({ branch_uuid: branchUuid, doctor_uuid: doctorUuid })}
>
رزرو همهٔ جلسات
</button>
)}
{canManage && course.status === 'active' && (
<button type="button" className="btn secondary sm" onClick={() => setAbandoning(true)}>
رهاکردن دوره
</button>
)}
</div>
{suggestion && suggestion.session_number !== null && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 }}>
<span>
جلسهٔ بعدی: <strong>{suggestion.session_number}</strong>
{suggestion.ideal_at !== undefined && ` · تاریخ ایده‌آل ${formatDate(suggestion.ideal_at)}`}
</span>
{suggestion.range && (
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>
بازهٔ مجاز: {formatDate(suggestion.range.min)} تا {formatDate(suggestion.range.max)}
</span>
)}
{suggestion.suggested_slots.length > 0 && (
<span style={{ color: 'var(--text-2)' }}>
نزدیکترین وقتها:{' '}
{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>
)}
</div>
)}
</div>
)}
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={course?.sessions ?? []}
loading={loading}
emptyMessage="این دوره جلسه‌ای ندارد"
/>
</div>
<ConfirmDialog
open={abandoning}
title="رهاکردن دوره"
message="جلسات باقی‌مانده برنامه‌ریزی‌شده می‌مانند و دوره از فهرست فعال خارج می‌شود."
confirmLabel="رهاکن"
danger
loading={abandon.isPending}
onCancel={() => setAbandoning(false)}
onConfirm={async () => {
await abandon.mutateAsync(reason.trim() || 'رهاکردن دوره');
setAbandoning(false);
}}
>
<div className="field-block">
<label htmlFor="abandon-reason">دلیل</label>
<input
id="abandon-reason"
className="input"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً: انصراف بیمار"
/>
</div>
</ConfirmDialog>
</div>
);
}
-213
View File
@@ -1,213 +0,0 @@
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, formatNumber, isoToUnix } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useWaitlist, useWaitlistMatches } from '../hooks/useCancellation';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import PersianDateInput from '../components/ui/PersianDateInput';
import type { WaitlistEntry } from '../types';
/**
* مرزهای ساعتش در بک‌اند است (`WaitlistEntry::DAY_PARTS`)؛ اینجا فقط ترجمهٔ نمایش.
* تعریف مرز در دو جا یعنی روزی که یکی عوض شود، بیمار برای ساعتی خبر شود که رد کرده بود.
*/
const DAY_PART_LABELS: Record<string, string> = {
morning: 'صبح',
afternoon: 'بعدازظهر',
evening: 'عصر',
};
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: '', match_service: '', match_at: '' });
const { entries, loading, remove } = useWaitlist(urlState.status || undefined);
/**
* «چه کسی برای **این** وقت منتظر است» — سؤالی که لحظهٔ آزاد شدن ظرفیت پرسیده می‌شود
* و فهرست کلی جوابش را نمی‌دهد.
*/
const { items: services } = useAllServiceItems();
const matchAt = isoToUnix(urlState.match_at) ?? 0;
const { matches, loading: matching } = useWaitlistMatches({
serviceUuid: urlState.match_service,
start: matchAt,
});
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.map((p) => DAY_PART_LABELS[p] ?? p).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 className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>خدمتِ آزادشده</label>
<SearchableSelect
value={urlState.match_service}
onChange={(v) => setUrlState({ match_service: String(v ?? '') })}
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="انتخاب خدمت"
isClearable
/>
</div>
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
<label>تاریخ وقت آزاد</label>
<PersianDateInput
value={urlState.match_at}
onChange={(v) => setUrlState({ match_at: v })}
/>
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)', paddingBottom: 8 }}>
{urlState.match_service === '' || matchAt === 0
? 'خدمت و تاریخ را انتخاب کنید تا منتظرانِ همان وقت را ببینید.'
: matching
? 'در حال بررسی…'
: `${formatNumber(matches.length)} نفر برای این وقت منتظرند`}
</span>
</div>
{urlState.match_service !== '' && matchAt > 0 && matches.length > 0 && (
<div className="card card-pad" style={{ marginBottom: 16 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قابل تطبیق با این وقت</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13 }}>
{matches.map((m) => (
<div key={m.uuid} style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>{m.service_name}</span>
<span style={{ color: 'var(--text-2)' }}>
{formatDate(m.desired_from)} تا {formatDate(m.desired_to)}
</span>
<span style={{ color: 'var(--text-3)' }}>
{m.preferred_day_parts.length === 0
? 'بی‌تفاوت'
: m.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')}
</span>
<span className={STATUS[m.status].className}>
<span className="bdot" />
{STATUS[m.status].label}
</span>
</div>
))}
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '10px 0 0', lineHeight: 1.8 }}>
لغو نوبت خودش این افراد را خبر میکند؛ این فهرست فقط برای دیدنِ پیش از تصمیم است.
</p>
</div>
)}
<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>
);
}