Remove unused pages: PolicySimulationPage, ResourceUtilizationPage, TreatmentCoursePage, WaitlistPage, and their associated tests
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user