From 9486721fa3af52cec1e8beb9c3db693f2e88dddc Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 20:25:42 +0330 Subject: [PATCH] Remove unused pages: PolicySimulationPage, ResourceUtilizationPage, TreatmentCoursePage, WaitlistPage, and their associated tests --- .../CancelAppointmentDialog.test.tsx | 96 ----- assets/admin/hooks/useCourses.ts | 138 ------- assets/admin/hooks/usePackages.ts | 120 ------ assets/admin/hooks/useReports.ts | 33 -- .../pages/CancellationPolicyPage.test.tsx | 61 --- assets/admin/pages/CancellationPolicyPage.tsx | 216 ----------- assets/admin/pages/CourseProtocolsPage.tsx | 349 ------------------ assets/admin/pages/PackagesPage.tsx | 279 -------------- .../pages/PatientPackageLedgerPage.test.tsx | 78 ---- .../admin/pages/PatientPackageLedgerPage.tsx | 220 ----------- assets/admin/pages/PlanAccuracyPage.test.tsx | 63 ---- assets/admin/pages/PlanAccuracyPage.tsx | 130 ------- assets/admin/pages/PoliciesPage.tsx | 147 -------- assets/admin/pages/PolicyFormPage.test.tsx | 84 ----- assets/admin/pages/PolicyFormPage.tsx | 280 -------------- .../admin/pages/PolicySimulationPage.test.tsx | 104 ------ assets/admin/pages/PolicySimulationPage.tsx | 184 --------- .../admin/pages/ResourceUtilizationPage.tsx | 189 ---------- .../admin/pages/TreatmentCoursePage.test.tsx | 93 ----- assets/admin/pages/TreatmentCoursePage.tsx | 343 ----------------- assets/admin/pages/WaitlistPage.tsx | 213 ----------- 21 files changed, 3420 deletions(-) delete mode 100644 assets/admin/components/CancelAppointmentDialog.test.tsx delete mode 100644 assets/admin/hooks/useCourses.ts delete mode 100644 assets/admin/hooks/usePackages.ts delete mode 100644 assets/admin/hooks/useReports.ts delete mode 100644 assets/admin/pages/CancellationPolicyPage.test.tsx delete mode 100644 assets/admin/pages/CancellationPolicyPage.tsx delete mode 100644 assets/admin/pages/CourseProtocolsPage.tsx delete mode 100644 assets/admin/pages/PackagesPage.tsx delete mode 100644 assets/admin/pages/PatientPackageLedgerPage.test.tsx delete mode 100644 assets/admin/pages/PatientPackageLedgerPage.tsx delete mode 100644 assets/admin/pages/PlanAccuracyPage.test.tsx delete mode 100644 assets/admin/pages/PlanAccuracyPage.tsx delete mode 100644 assets/admin/pages/PoliciesPage.tsx delete mode 100644 assets/admin/pages/PolicyFormPage.test.tsx delete mode 100644 assets/admin/pages/PolicyFormPage.tsx delete mode 100644 assets/admin/pages/PolicySimulationPage.test.tsx delete mode 100644 assets/admin/pages/PolicySimulationPage.tsx delete mode 100644 assets/admin/pages/ResourceUtilizationPage.tsx delete mode 100644 assets/admin/pages/TreatmentCoursePage.test.tsx delete mode 100644 assets/admin/pages/TreatmentCoursePage.tsx delete mode 100644 assets/admin/pages/WaitlistPage.tsx diff --git a/assets/admin/components/CancelAppointmentDialog.test.tsx b/assets/admin/components/CancelAppointmentDialog.test.tsx deleted file mode 100644 index 3319c473..00000000 --- a/assets/admin/components/CancelAppointmentDialog.test.tsx +++ /dev/null @@ -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; -const post = api.post as ReturnType; - -const preview = (over: Record = {}) => ({ - 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( - {}} />, - ); - - // مبلغ‌ها به تومان نمایش داده می‌شوند: ۲۵۰٬۰۰۰ ریال ⇒ ۲۵٬۰۰۰ تومان. - 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( - {}} />, - ); - - 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( - {}} />, - ); - - 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( - {}} />, - ); - - await waitFor(() => - expect(screen.getByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument(), - ); - - expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled(); - }); -}); diff --git a/assets/admin/hooks/useCourses.ts b/assets/admin/hooks/useCourses.ts deleted file mode 100644 index a867531c..00000000 --- a/assets/admin/hooks/useCourses.ts +++ /dev/null @@ -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>('/api/v1/course-protocols'), - }); - - const invalidate = () => qc.invalidateQueries({ queryKey: PROTOCOLS_KEY }); - - const create = useMutation({ - mutationFn: (body: Record) => - api.post>('/api/v1/course-protocols', body), - onSuccess: () => { - toast.success('پروتکل ساخته شد'); - invalidate(); - }, - onError: (e) => fail(e, 'ساخت پروتکل ناموفق بود'), - }); - - const update = useMutation({ - mutationFn: ({ uuid, body }: { uuid: string; body: Record }) => - api.patch>(`/api/v1/course-protocol/${uuid}`, body), - onSuccess: () => { - toast.success('پروتکل به‌روزرسانی شد'); - invalidate(); - }, - onError: (e) => fail(e, 'به‌روزرسانی پروتکل ناموفق بود'), - }); - - const deactivate = useMutation({ - mutationFn: (uuid: string) => api.delete>(`/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>(`/api/v1/patient/${patientUuid}/courses`), - enabled: !!patientUuid, - }); - - const start = useMutation({ - mutationFn: (body: { protocol_uuid: string; patient_package_uuid?: string }) => - api.post>('/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>(`/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>(`/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>( - `/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>( - `/api/v1/treatment-course/${courseUuid}/next-slot-suggestion?branch_uuid=${branchUuid}`, - ), - enabled: !!courseUuid && !!branchUuid, - }); - - return { suggestion: query.data?.data, loading: query.isLoading }; -} diff --git a/assets/admin/hooks/usePackages.ts b/assets/admin/hooks/usePackages.ts deleted file mode 100644 index ca0f01c2..00000000 --- a/assets/admin/hooks/usePackages.ts +++ /dev/null @@ -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>('/api/v1/packages'), - }); - - const invalidate = () => qc.invalidateQueries({ queryKey: PACKAGES_KEY }); - - const create = useMutation({ - mutationFn: (body: Record) => - api.post>('/api/v1/packages', body), - onSuccess: () => { - toast.success('پکیج ساخته شد'); - invalidate(); - }, - onError: (e) => fail(e, 'ساخت پکیج ناموفق بود'), - }); - - const update = useMutation({ - mutationFn: ({ uuid, body }: { uuid: string; body: Record }) => - api.patch>(`/api/v1/package/${uuid}`, body), - onSuccess: () => { - toast.success('پکیج به‌روزرسانی شد'); - invalidate(); - }, - onError: (e) => fail(e, 'به‌روزرسانی پکیج ناموفق بود'), - }); - - /** حذف = غیرفعال کردن؛ پکیج فروخته‌شده حذف‌شدنی نیست. */ - const deactivate = useMutation({ - mutationFn: (uuid: string) => api.delete>(`/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>(`/api/v1/patient/${patientUuid}/packages`), - enabled: !!patientUuid, - }); - - const sell = useMutation({ - mutationFn: (body: { package_uuid: string; price_paid_rials?: number }) => - api.post>(`/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>(`/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>(`/api/v1/patient-package/${patientPackageUuid}/adjust`, body), - onSuccess: () => { - toast.success('اصلاح اعتبار ثبت شد'); - invalidate(); - }, - onError: (e) => fail(e, 'اصلاح اعتبار ناموفق بود'), - }); - - const expire = useMutation({ - mutationFn: (reason: string) => - api.post>(`/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 }; -} diff --git a/assets/admin/hooks/useReports.ts b/assets/admin/hooks/useReports.ts deleted file mode 100644 index 356d92d2..00000000 --- a/assets/admin/hooks/useReports.ts +++ /dev/null @@ -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>>( - `/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>>( - `/api/v1/reports/plan-accuracy?from=${from}&to=${to}`, - ), - }); - - return { rows: query.data?.data?.rows ?? [], loading: query.isLoading }; -} diff --git a/assets/admin/pages/CancellationPolicyPage.test.tsx b/assets/admin/pages/CancellationPolicyPage.test.tsx deleted file mode 100644 index 4229e21e..00000000 --- a/assets/admin/pages/CancellationPolicyPage.test.tsx +++ /dev/null @@ -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; -const put = api.put as ReturnType; - -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(, { 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(, { 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(); - }); -}); diff --git a/assets/admin/pages/CancellationPolicyPage.tsx b/assets/admin/pages/CancellationPolicyPage.tsx deleted file mode 100644 index f5fa17ca..00000000 --- a/assets/admin/pages/CancellationPolicyPage.tsx +++ /dev/null @@ -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 ( -
- - -
- {loading && در حال بارگذاری…} - -
- - setFreeWindow(Number(e.target.value))} - /> - - لغو زودتر از این، همیشه رایگان است. - -
- -
- - setMode((v as 'none' | 'percent' | 'fixed') ?? 'none')} - options={MODES} - /> -
- - {mode === 'percent' && ( -
- - setValue(Number(e.target.value))} - /> - {percentInvalid && ( - درصد باید بین ۰ تا ۱۰۰ باشد. - )} -
- )} - - {mode === 'fixed' && ( -
- - -
- )} - - - جریمه هرگز از مبلغ پرداختی بیمار بیشتر نمی‌شود؛ نوبت نقدی جریمه‌ای ندارد. - - - - - - -
- - setThreshold(Number(e.target.value))} - /> - - بعد از این تعداد در یک سال، بیمار برچسب پرریسک می‌گیرد — ولی مسدود نمی‌شود. - -
- - {canManage && ( -
- -
- )} -
- - {/* سیاست سرویس و سیاست محیط **ترکیب نمی‌شوند** — سرویس اگر سیاست دارد، همه‌اش - مال اوست. بدون این جدول، اپراتور نمی‌داند کدام خدمت‌ها از قاعدهٔ عمومی - پیروی نمی‌کنند و چرا عدد جریمهٔ آن‌ها فرق دارد. */} -
-

سیاست‌های اختصاصی خدمات

-

- خدمتی که سیاست خودش را دارد، **کاملاً** از آن پیروی می‌کند و سیاست بالا رویش - اثری ندارد. ساخت و ویرایشش از صفحهٔ همان خدمت انجام می‌شود. -

- - {overrides.length === 0 ? ( - - هیچ خدمتی سیاست اختصاصی ندارد؛ همه از سیاست بالا پیروی می‌کنند. - - ) : ( -
- {overrides.map((o) => ( -
- - {o.service_name ?? o.service_uuid} - - - رایگان تا {formatNumber(o.free_window_hours)} ساعت قبل - - - {o.penalty_mode === 'none' - ? 'بدون جریمه' - : o.penalty_mode === 'percent' - ? `جریمه ${formatNumber(o.penalty_value)}٪` - : `جریمه ${formatRial(o.penalty_value)}`} - - {!o.active && ( - - - غیرفعال - - )} -
- ))} -
- )} -
-
- ); -} diff --git a/assets/admin/pages/CourseProtocolsPage.tsx b/assets/admin/pages/CourseProtocolsPage.tsx deleted file mode 100644 index 7c8af3df..00000000 --- a/assets/admin/pages/CourseProtocolsPage.tsx +++ /dev/null @@ -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(null); - - const { data: servicesData } = useQuery({ - queryKey: ['service-items-for-courses'], - queryFn: () => api.get>('/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[] = [ - { - key: 'service_name', - header: 'سرویس', - render: (p) => {p.service_name}, - }, - { - key: 'session_count', - header: 'تعداد جلسه', - render: (p) => {p.session_count}, - }, - { - key: 'spacing', - header: 'فاصله (روز)', - render: (p) => ( - - حداقل {p.min_days} · ایده‌آل {p.ideal_days} · حداکثر {p.max_days} - - ), - }, - { - key: 'steps', - header: 'پارامتر جلسات', - render: (p) => ( - - {p.steps.length === 0 ? '—' : `${p.steps.length} جلسه پارامتر دارد`} - - ), - }, - { - key: 'active', - header: 'وضعیت', - render: (p) => , - }, - ]; - - 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 ( -
- setDraft({ ...EMPTY, steps: [] })}> - پروتکل تازه - - ) : undefined - } - /> - - setUrlState({ search: v })} - searchPlaceholder="جستجو در پروتکل‌ها..." - emptyMessage="هنوز پروتکلی تعریف نشده است" - actions={(p) => - canManage ? ( -
- - {p.active && ( - - )} -
- ) : null - } - /> - - setDraft(null)} - footer={ - <> - - - - } - > - {draft && ( -
- {!draft.uuid && ( -
- - setDraft({ ...draft, service_uuid: String(v ?? '') })} - options={services.map((s) => ({ value: s.uuid, label: s.name }))} - placeholder="انتخاب سرویس" - /> - هر سرویس یک پروتکل دارد. -
- )} - -
- - setDraft({ ...draft, session_count: Number(e.target.value) })} - /> - {draft.session_count < 2 && ( - - دورهٔ کمتر از دو جلسه همان نوبت تکی است. - - )} -
- -
- {([ - ['min_days', 'حداقل (روز)'], - ['ideal_days', 'ایده‌آل (روز)'], - ['max_days', 'حداکثر (روز)'], - ] as const).map(([key, label]) => ( -
- - setDraft({ ...draft, [key]: Number(e.target.value) })} - /> -
- ))} -
- - {orderInvalid && ( - - ترتیب باید حداقل ≤ ایده‌آل ≤ حداکثر باشد. - - )} - - - -
-

پارامتر جلسات (اختیاری)

- - {draft.steps.map((step, index) => ( -
- - setDraft({ - ...draft, - steps: draft.steps.map((s, i) => - i === index ? { ...s, session_number: Number(e.target.value) } : s, - ), - }) - } - /> - - setDraft({ - ...draft, - steps: draft.steps.map((s, i) => (i === index ? { ...s, energy: e.target.value } : s)), - }) - } - /> - -
- ))} - - -
-
- )} -
-
- ); -} diff --git a/assets/admin/pages/PackagesPage.tsx b/assets/admin/pages/PackagesPage.tsx deleted file mode 100644 index 3d6bed06..00000000 --- a/assets/admin/pages/PackagesPage.tsx +++ /dev/null @@ -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(null); - - const { data: servicesData } = useQuery({ - queryKey: ['service-items-for-packages'], - queryFn: () => api.get>('/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[] = [ - { - key: 'name', - header: 'پکیج', - render: (p) => ( -
- {p.name} - - {p.services.map((s) => s.name).join('، ') || 'بدون سرویس'} - -
- ), - }, - { - key: 'session_count', - header: 'تعداد جلسه', - render: (p) => {p.session_count}, - }, - { - key: 'price_rials', - header: 'قیمت', - render: (p) => {formatRial(p.price_rials)}, - }, - { - key: 'validity_days', - header: 'اعتبار', - render: (p) => ( - - {p.validity_days === null ? 'بی‌پایان' : `${p.validity_days} روز`} - - ), - }, - { - key: 'active', - header: 'وضعیت', - render: (p) => , - }, - ]; - - 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 ( -
- setDraft({ ...EMPTY })}> - پکیج تازه - - ) : undefined - } - /> - - setUrlState({ search: v })} - searchPlaceholder="جستجو در پکیج‌ها..." - emptyMessage="هنوز پکیجی تعریف نشده است" - actions={(p) => - canManage ? ( -
- - {p.active && ( - - )} -
- ) : null - } - /> - - setDraft(null)} - footer={ - <> - - - - } - > - {draft && ( -
-
- - setDraft({ ...draft, name: e.target.value })} - placeholder="۶ جلسه لیزر فول‌بادی" - /> -
- -
- - setDraft({ ...draft, session_count: Number(e.target.value) })} - /> -
- -
- - setDraft({ ...draft, price_rials: v })} - suffix="ریال" - /> -
- -
- - - setDraft({ ...draft, validity_days: e.target.value === '' ? '' : Number(e.target.value) }) - } - placeholder="خالی یعنی بی‌پایان" - /> -
- -
- - { - 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="افزودن سرویس" - /> -
- {draft.service_uuids.map((uuid) => ( - - ))} -
- {draft.service_uuids.length === 0 && ( - - پکیج بدون سرویس قابل مصرف نیست. - - )} -
-
- )} -
-
- ); -} diff --git a/assets/admin/pages/PatientPackageLedgerPage.test.tsx b/assets/admin/pages/PatientPackageLedgerPage.test.tsx deleted file mode 100644 index d6e9ca34..00000000 --- a/assets/admin/pages/PatientPackageLedgerPage.test.tsx +++ /dev/null @@ -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; - -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( - - } /> - , - { 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'); - }); -}); diff --git a/assets/admin/pages/PatientPackageLedgerPage.tsx b/assets/admin/pages/PatientPackageLedgerPage.tsx deleted file mode 100644 index 8d05c362..00000000 --- a/assets/admin/pages/PatientPackageLedgerPage.tsx +++ /dev/null @@ -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 = { - 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 = {}; - 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[] = [ - { - key: 'created_at', - header: 'تاریخ', - render: (r) => {formatDate(r.created_at)}, - }, - { - key: 'kind', - header: 'نوع', - render: (r) => {KIND_LABELS[r.kind]}, - }, - { - key: 'delta', - header: 'تغییر', - render: (r) => ( - 0 ? 'var(--success)' : 'var(--danger)' }}> - {r.delta > 0 ? `+${r.delta}` : r.delta} - - ), - }, - { - key: 'running_balance', - header: 'مانده', - // ⚠️ در UI جمع می‌شود، نه از سرور خوانده. - // - // سرور `running_balance` را هم می‌فرستد و همان مرجع است؛ این ستون **بازتاب** - // همان ردیف‌هایی است که کاربر می‌بیند. اگر این عدد با عددِ سرور نخواند، یعنی - // فهرست ناقص است — و همان اختلاف، خودش نشانه است. - render: (r) => {runningBalance[r.uuid] ?? r.running_balance}, - }, - { - key: 'reason', - header: 'دلیل', - render: (r) => ( - - {r.reason ?? r.service_name ?? '—'} - - ), - }, - { - // «چه کسی» و «کدام نوبت» از قبل در پاسخ بودند و نمایش داده نمی‌شدند. دفترِ - // اصلاح‌پذیر بدون نامِ اصلاح‌کننده، نصف حسابرسی است. - key: 'created_by', - header: 'ثبت‌کننده', - render: (r) => ( - {r.created_by ?? 'سیستم'} - ), - }, - { - key: 'appointment_uuid', - header: 'نوبت', - render: (r) => - r.appointment_uuid ? ( - - مشاهدهٔ نوبت - - ) : ( - - ), - }, - ]; - - return ( -
- - - {ledger && ( -
- - مانده: {ledger.package.balance} از {ledger.package.session_count} جلسه - - - پرداختی: {formatRial(ledger.package.price_paid_rials)} - - - خرید: {formatDate(ledger.package.purchased_at)} - {ledger.package.valid_to !== null && ` · اعتبار تا ${formatDate(ledger.package.valid_to)}`} - - {ledger.package.expired && منقضی} - - {canCorrect && ( -
- - -
- )} -
- )} - -
- -
- - setAdjusting(false)} - footer={ - <> - - - - } - > -
-
- - setDelta(Number(e.target.value))} - /> -
- -
- - setReason(e.target.value)} - placeholder="مثلاً: جبران جلسهٔ لغوشده توسط کلینیک" - /> - - دلیل در دفتر ثبت می‌شود و بعداً قابل ویرایش نیست. - -
-
-
-
- ); -} diff --git a/assets/admin/pages/PlanAccuracyPage.test.tsx b/assets/admin/pages/PlanAccuracyPage.test.tsx deleted file mode 100644 index 1cd57194..00000000 --- a/assets/admin/pages/PlanAccuracyPage.test.tsx +++ /dev/null @@ -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; - -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(, { 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(, { route: '/admin/reports/plan-accuracy' }); - - await waitFor(() => - expect(screen.getByText(/کمتر از سه نوبت انجام‌شده در گزارش نمی‌آیند/)).toBeInTheDocument(), - ); - }); -}); diff --git a/assets/admin/pages/PlanAccuracyPage.tsx b/assets/admin/pages/PlanAccuracyPage.tsx deleted file mode 100644 index 8ba8c064..00000000 --- a/assets/admin/pages/PlanAccuracyPage.tsx +++ /dev/null @@ -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 = { - 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[] = [ - { - key: 'service_name', - header: 'خدمت', - render: (r) => ( -
- {r.service_name} - {r.sample_size} نوبت -
- ), - }, - { - key: 'planned_minutes', - header: 'پیش‌بینی', - render: (r) => {r.planned_minutes} دقیقه, - }, - { - key: 'actual_minutes', - header: 'واقعی', - render: (r) => {r.actual_minutes} دقیقه, - }, - { - key: 'deviation_percent', - header: 'انحراف', - render: (r) => ( - - {r.deviation_percent > 0 ? `+${r.deviation_percent}` : r.deviation_percent}٪ - - ), - }, - { - key: 'fix', - header: '', - // گزارشی که راه اصلاح ندهد خوانده نمی‌شود. - render: (r) => ( - - ویرایش بخش‌های این خدمت - - ), - }, - { - key: 'severity', - header: 'شدت', - render: (r) => ( - - - {SEVERITY[r.severity ?? 'unrated'].label} - - ), - }, - ]; - - return ( -
- - -
-
- - setDays(String(v ?? '30'))} options={RANGES} /> -
- - خدماتی با کمتر از سه نوبت انجام‌شده در گزارش نمی‌آیند. - -
- -
- -
-
- ); -} diff --git a/assets/admin/pages/PoliciesPage.tsx b/assets/admin/pages/PoliciesPage.tsx deleted file mode 100644 index 8423549e..00000000 --- a/assets/admin/pages/PoliciesPage.tsx +++ /dev/null @@ -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[] = [ - { - key: 'name', - header: 'قانون', - render: (p) => ( -
- {p.name} - - {schema?.[p.category]?.label ?? p.category} · نسخهٔ {p.version} - -
- ), - }, - { - key: 'effects', - header: 'اثر', - render: (p) => ( - - {p.effects - .map( - (e) => - schema?.[p.category]?.effects.find((m) => m.type === e.type)?.label ?? e.type, - ) - .join('، ') || '—'} - - ), - }, - { - key: 'scope', - header: 'دامنه', - render: (p) => ( - - {p.address_uuid ? 'شعبه' : p.service_uuid ? 'سرویس' : p.catalog_category_uuid ? 'دسته' : 'کل محیط'} - - ), - }, - { - key: 'priority', - header: 'اولویت', - render: (p) => {p.priority}, - }, - { - key: 'active', - header: 'وضعیت', - render: (p) => , - }, - ]; - - return ( -
- - قانون تازه - - ) : undefined - } - /> - - setUrlState({ search: v })} - searchPlaceholder="جستجو در قوانین..." - emptyMessage="هنوز قانونی تعریف نشده است" - headerExtra={ -
- setUrlState({ category: String(v ?? '') })} - options={[{ value: '', label: 'همهٔ دسته‌ها' }, ...categoryOptions]} - placeholder="دسته" - /> -
- } - actions={(p) => ( -
- - {canManage && p.active && ( - - )} -
- )} - /> -
- ); -} diff --git a/assets/admin/pages/PolicyFormPage.test.tsx b/assets/admin/pages/PolicyFormPage.test.tsx deleted file mode 100644 index 0f73f9a2..00000000 --- a/assets/admin/pages/PolicyFormPage.test.tsx +++ /dev/null @@ -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; - -/** - * فیلدِ ساختگی: در هیچ کجای فرانت نوشته نشده. اگر در صفحه ظاهر شود، یعنی فرم واقعاً - * از 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(, { 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(, { route: '/admin/policies/new' }); - fireEvent.click(screen.getByText('پیشرفته')); - - await waitFor(() => expect(screen.getByText('حداقل مدت (دقیقه)')).toBeInTheDocument()); - expect(screen.getByText(/ترکیب: max/)).toBeInTheDocument(); - }); -}); diff --git a/assets/admin/pages/PolicyFormPage.tsx b/assets/admin/pages/PolicyFormPage.tsx deleted file mode 100644 index 072271d8..00000000 --- a/assets/admin/pages/PolicyFormPage.tsx +++ /dev/null @@ -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('template'); - const [name, setName] = useState(''); - const [priority, setPriority] = useState(0); - - const [templateKey, setTemplateKey] = useState(''); - const [values, setValues] = useState>({}); - - const [category, setCategory] = useState('timing'); - const [match, setMatch] = useState<'all' | 'any'>('all'); - const [clauses, setClauses] = useState([]); - const [effects, setEffects] = useState([]); - - 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 ( -
- - -
-
- - -
- -
- - setName(e.target.value)} - placeholder="مثلاً: حداقل ۲۱ روز فاصله بین جلسات لیزر" - /> - - این نام در پیام خطا به بیمار نشان داده می‌شود؛ قابل فهم بنویسید. - -
- -
- - setPriority(Number(e.target.value))} - /> - - در تناقض، عدد بزرگ‌تر برنده است. - -
- - {mode === 'template' ? ( - <> -
- - { - setTemplateKey(String(v ?? '')); - setValues({}); - }} - options={templates.map((t) => ({ value: t.key, label: t.title }))} - placeholder="یک الگو انتخاب کنید" - /> - {template && ( - {template.description} - )} -
- - {template?.inputs.map((input) => ( -
- - setValues({ ...values, [input.key]: e.target.value })} - /> -
- ))} - - ) : ( - <> -
- - { - setCategory((v as PolicyCategory) ?? 'timing'); - // فیلدها و اثرهای مجاز per دسته فرق دارند؛ نگه‌داشتنشان یعنی ۴۲۲. - setClauses([]); - setEffects([]); - }} - options={categoryOptions} - /> -
- - {categorySchema && ( - <> -
-

شرط

- { - setMatch(m); - setClauses(c); - }} - /> -
- -
-

اثر

-
- {categorySchema.effects.map((meta) => { - const current = effects.find((e) => e.type === meta.type); - - return ( -
- - - {current && meta.value_type !== 'none' && ( - - 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' && ( - - setEffects( - effects.map((x) => - x.type === 'forbid' ? { ...x, reason: e.target.value } : x, - ), - ) - } - /> - )} - - - ترکیب: {meta.combination} - -
- ); - })} -
-
- - )} - - )} - -
- - -
-
-
- ); -} diff --git a/assets/admin/pages/PolicySimulationPage.test.tsx b/assets/admin/pages/PolicySimulationPage.test.tsx deleted file mode 100644 index aebe4060..00000000 --- a/assets/admin/pages/PolicySimulationPage.test.tsx +++ /dev/null @@ -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; - -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( - - } /> - , - { 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()); - }); -}); diff --git a/assets/admin/pages/PolicySimulationPage.tsx b/assets/admin/pages/PolicySimulationPage.tsx deleted file mode 100644 index 22f7ed19..00000000 --- a/assets/admin/pages/PolicySimulationPage.tsx +++ /dev/null @@ -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 = { - 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(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[] = [ - { - key: 'patient_name', - header: 'بیمار', - render: (r) => {r.patient_name}, - }, - { - key: 'slot_start', - header: 'تاریخ نوبت', - render: (r) => {formatDate(r.slot_start)}, - }, - { - key: 'change', - header: 'وضعیت فعلی ← با این قانون', - render: (r) => ( - - {r.before} - {' ← '} - {r.after} - - ), - }, - { - key: 'reason', - header: 'توضیح', - render: (r) => {r.reason}, - }, - ]; - - return ( -
- setLatest((await run.mutateAsync(50)).data)} - > - اجرای آزمایش - - } - /> - - {current && severity && ( -
-
- - نمونه: {current.sample_size} نوبت · تحت تأثیر: {current.affected_count} نوبت ( - {current.affected_percent}٪) - - - شدت: {severity.label} - - - نسخهٔ آزمایش‌شده: {current.policy_version} - -
- -

- {current.warning ?? severity.note} -

- - {!isCurrentVersion && ( -

- این گزارش برای نسخهٔ {current.policy_version} است و قانون اکنون نسخهٔ{' '} - {policy?.version} است. برای فعال‌سازی، دوباره آزمایش کنید. -

- )} - - {canManage && ( -
- - -
- )} -
- )} - - setConfirming(false)} - onConfirm={() => { - setConfirming(false); - activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') }); - }} - /> - - {policy?.versions && policy.versions.length > 1 && ( - - )} - -
- -
-
- ); -} diff --git a/assets/admin/pages/ResourceUtilizationPage.tsx b/assets/admin/pages/ResourceUtilizationPage.tsx deleted file mode 100644 index 8b7c9aba..00000000 --- a/assets/admin/pages/ResourceUtilizationPage.tsx +++ /dev/null @@ -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[] = [ - { - key: 'resource_name', - header: 'منبع', - render: (r) => ( -
- {r.resource_name} - {r.role} -
- ), - }, - { - key: 'available_minutes', - header: 'در دسترس', - render: (r) => {r.available_minutes} دقیقه, - }, - { - key: 'occupied_minutes', - header: 'اشغال', - render: (r) => {r.occupied_minutes} دقیقه, - }, - { - key: 'active_minutes', - header: 'کار مفید', - render: (r) => {r.active_minutes} دقیقه, - }, - { - key: 'utilization', - header: 'بهره‌وری', - // `null` یعنی «تعریف‌نشده» نه «صفر»، و کار بعدی روشن است: تقویم منبع را بساز. - // بدون لینک، کاربر عدد را می‌بیند و نمی‌داند کجا باید برود. - render: (r) => - r.utilization === null ? ( - - تنظیم تقویم - - ) : ( - {percent(r.utilization)} - ), - }, - { - key: 'active_ratio', - header: 'نسبت کار مفید', - // توضیح باید در خودِ صفحه باشد، نه فقط در مستندات: کسی که گزارش را می‌خواند - // مستندات را باز نمی‌کند. - render: (r) => ( - - {percent(r.active_ratio)} - - ), - }, - { - key: 'wasted_capacity', - header: '', - render: (r) => - r.wasted_capacity ? ( - ظرفیت هدررفته - ) : null, - }, - ]; - - return ( -
- - -
-
- - setBranchUuid(String(v ?? ''))} - options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} - placeholder="انتخاب شعبه" - /> -
- -
- - setDays(String(v ?? '7'))} options={RANGES} /> -
- - {days === 'custom' && ( - <> -
- - setUrlState({ from: v })} - /> -
-
- - setUrlState({ to: v })} - /> -
- - )} -
- -

- «اشغال» شامل آماده‌سازی، تمیزکاری و بخش‌های انتظار است؛ «کار مفید» فقط زمانی که - بیمار حاضر بوده. فاصلهٔ این دو نشان می‌دهد بخش‌های نوبت درست تعریف شده‌اند یا نه. -

- - - -
- -
-
- ); -} diff --git a/assets/admin/pages/TreatmentCoursePage.test.tsx b/assets/admin/pages/TreatmentCoursePage.test.tsx deleted file mode 100644 index 8d034230..00000000 --- a/assets/admin/pages/TreatmentCoursePage.test.tsx +++ /dev/null @@ -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('react-router-dom')), - useParams: () => ({ courseUuid: 'c-1' }), -})); - -import { api } from '../lib/api'; -import TreatmentCoursePage from './TreatmentCoursePage'; - -const get = api.get as ReturnType; - -const DAY = 86400; -const now = () => Math.floor(Date.now() / 1000); - -function course(over: Record = {}) { - 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) { - 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(); - - 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(); - - 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(); - - expect(await screen.findByText('۳۰ روز')).toBeInTheDocument(); - }); -}); diff --git a/assets/admin/pages/TreatmentCoursePage.tsx b/assets/admin/pages/TreatmentCoursePage.tsx deleted file mode 100644 index dd4f1820..00000000 --- a/assets/admin/pages/TreatmentCoursePage.tsx +++ /dev/null @@ -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 = { - 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>({ - 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[] = [ - { - key: 'session_number', - header: 'جلسه', - render: (s) => {s.session_number}, - }, - { - key: 'status', - header: 'وضعیت', - render: (s) => ( - - - {SESSION_STATUS[s.status].label} - - ), - }, - { - key: 'slot_start', - header: 'تاریخ نوبت', - render: (s) => ( - {s.slot_start === null ? '—' : formatDate(s.slot_start)} - ), - }, - { - key: 'gap', - header: 'فاصله با قبلی', - render: (s) => { - const gap = gapBefore(s); - - if (gap === null) return ; - - const tooLong = course !== undefined && gap > course.max_days; - - return ( - - {formatNumber(gap)} روز - - ); - }, - }, - { - key: 'params', - header: 'پارامتر', - render: (s) => ( - - {Object.entries(s.params).length === 0 - ? '—' - : Object.entries(s.params) - .map(([k, v]) => `${k}: ${v}`) - .join('، ')} - - ), - }, - { - key: 'completed_at', - header: 'انجام‌شده در', - render: (s) => ( - - {s.completed_at === null ? '—' : formatDate(s.completed_at)} - - ), - }, - ]; - - return ( -
- - - {course && ( -
-
- - جلسهٔ {course.progress.completed} از {course.progress.total} انجام شده - - - رزروشده: {course.progress.booked} · باقی‌مانده: {course.progress.planned} - - - فاصله: حداقل {course.min_days} · ایده‌آل {course.ideal_days} · حداکثر {course.max_days} روز - - {course.status !== 'active' && ( - - - {course.status === 'completed' ? 'تمام‌شده' : 'رهاشده'} - - )} -
- - {/* پکیجی که کفاف جلسات باقی‌مانده را نمی‌دهد، دوره را باطل نمی‌کند — بقیه‌اش - نقدی می‌شود. ولی باید قبل از جلسهٔ ششم دانسته شود، نه سرِ آن. */} - {(course.package_shortfall ?? 0) > 0 && ( - - اعتبار پکیج برای {formatNumber(course.package_shortfall ?? 0)} جلسه کم می‌آید - (مانده: {formatNumber(course.package_balance ?? 0)}). بقیهٔ جلسات با قیمت - عادی حساب می‌شوند. - - )} - - {overdue !== null && ( - - {formatNumber(overdue)} روز از آخرین جلسه گذشته — بیشتر از حداکثر{' '} - {formatNumber(course.max_days)} روزِ پروتکل. جلسهٔ بعدی را دوباره زمان‌بندی کنید. - - )} - - {course.abandon_reason && ( - دلیل رهاکردن: {course.abandon_reason} - )} - -
-
- - setBranchUuid(String(v ?? ''))} - options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} - placeholder="انتخاب شعبه" - /> -
- - {/* رزرو گروهی پزشک لازم دارد؛ خودِ دوره پزشکی ندارد چون هر جلسه می‌تواند - با پزشک دیگری باشد. */} - {canManage && course.status === 'active' && ( -
- - setDoctorUuid(String(v ?? ''))} - options={doctors.map((d) => ({ value: d.uuid, label: d.name }))} - placeholder="انتخاب پزشک" - /> -
- )} - - {canManage && course.status === 'active' && ( - - )} - - {canManage && course.status === 'active' && ( - - )} -
- - {suggestion && suggestion.session_number !== null && ( -
- - جلسهٔ بعدی: {suggestion.session_number} - {suggestion.ideal_at !== undefined && ` · تاریخ ایده‌آل ${formatDate(suggestion.ideal_at)}`} - - {suggestion.range && ( - - بازهٔ مجاز: {formatDate(suggestion.range.min)} تا {formatDate(suggestion.range.max)} - - )} - {suggestion.suggested_slots.length > 0 && ( - - نزدیک‌ترین وقت‌ها:{' '} - {suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')} - - )} - {/* ترجیح است نه الزام: موتور همان منبع را جلوتر می‌آورد ولی اگر آزاد نباشد - منبع دیگری می‌دهد. متن هم همین را می‌گوید تا انتظار اشتباه نسازد. */} - {course.preferred_resource_name && ( - - ترجیح دوره: {course.preferred_resource_name} — اگر آزاد نباشد منبع دیگری - پیشنهاد می‌شود. - - )} - {suggestion.warning && ( - {suggestion.warning} - )} -
- )} -
- )} - -
- -
- - setAbandoning(false)} - onConfirm={async () => { - await abandon.mutateAsync(reason.trim() || 'رهاکردن دوره'); - setAbandoning(false); - }} - > -
- - setReason(e.target.value)} - placeholder="مثلاً: انصراف بیمار" - /> -
-
-
- ); -} diff --git a/assets/admin/pages/WaitlistPage.tsx b/assets/admin/pages/WaitlistPage.tsx deleted file mode 100644 index c75859a4..00000000 --- a/assets/admin/pages/WaitlistPage.tsx +++ /dev/null @@ -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 = { - morning: 'صبح', - afternoon: 'بعدازظهر', - evening: 'عصر', -}; - -const STATUS: Record = { - 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[] = [ - { - key: 'service_name', - header: 'خدمت', - render: (e) => {e.service_name}, - }, - { - key: 'window', - header: 'بازهٔ دلخواه', - render: (e) => ( - - {formatDate(e.desired_from)} تا {formatDate(e.desired_to)} - - ), - }, - { - key: 'preferred_day_parts', - header: 'زمان ترجیحی', - render: (e) => ( - - {e.preferred_day_parts.length === 0 - ? 'بی‌تفاوت' - : e.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')} - - ), - }, - { - key: 'notify_count', - header: 'تعداد اطلاع', - render: (e) => ( - - {e.notify_count} - {e.notified_at !== null && ( - · {formatDate(e.notified_at)} - )} - - ), - }, - { - key: 'status', - header: 'وضعیت', - render: (e) => ( - - - {STATUS[e.status].label} - - ), - }, - ]; - - return ( -
- - -
-
- - setUrlState({ match_service: String(v ?? '') })} - options={services.map((s) => ({ value: s.uuid, label: s.name }))} - placeholder="انتخاب خدمت" - isClearable - /> -
- -
- - setUrlState({ match_at: v })} - /> -
- - - {urlState.match_service === '' || matchAt === 0 - ? 'خدمت و تاریخ را انتخاب کنید تا منتظرانِ همان وقت را ببینید.' - : matching - ? 'در حال بررسی…' - : `${formatNumber(matches.length)} نفر برای این وقت منتظرند`} - -
- - {urlState.match_service !== '' && matchAt > 0 && matches.length > 0 && ( -
-

قابل تطبیق با این وقت

-
- {matches.map((m) => ( -
- {m.service_name} - - {formatDate(m.desired_from)} تا {formatDate(m.desired_to)} - - - {m.preferred_day_parts.length === 0 - ? 'بی‌تفاوت' - : m.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')} - - - - {STATUS[m.status].label} - -
- ))} -
-

- لغو نوبت خودش این افراد را خبر می‌کند؛ این فهرست فقط برای دیدنِ پیش از تصمیم است. -

-
- )} - -
- setUrlState({ search: v })} - searchPlaceholder="جستجو در خدمات..." - emptyMessage="کسی در لیست انتظار نیست" - headerExtra={ -
- setUrlState({ status: String(v ?? '') })} - options={[ - { value: '', label: 'همهٔ وضعیت‌ها' }, - ...Object.entries(STATUS).map(([value, meta]) => ({ value, label: meta.label })), - ]} - placeholder="وضعیت" - /> -
- } - actions={(e) => - canManage ? ( - - ) : null - } - /> -
-
- ); -}