diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index af9b3ccb..99e9b4e0 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -79,7 +79,6 @@ import AppointmentSettingsPage from './pages/AppointmentSettingsPage'; import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage'; import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; -import ResourceBookingPage from './pages/ResourceBookingPage'; import ResourcesPage from './pages/ResourcesPage'; import ResourceTypesPage from './pages/ResourceTypesPage'; import CatalogCategoriesPage from './pages/CatalogCategoriesPage'; @@ -301,7 +300,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/assets/admin/components/HoldCountdown.test.tsx b/assets/admin/components/HoldCountdown.test.tsx deleted file mode 100644 index 79d7554c..00000000 --- a/assets/admin/components/HoldCountdown.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import HoldCountdown from './HoldCountdown'; - -describe('HoldCountdown', () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); - - const nowSeconds = () => Math.floor(Date.now() / 1000); - - it('counts down from the server expiry', () => { - render( {}} />); - - expect(screen.getByText(/2:05/)).toBeInTheDocument(); - - act(() => { vi.advanceTimersByTime(5000); }); - - expect(screen.getByText(/2:00/)).toBeInTheDocument(); - }); - - /** ⭐ رزروی که در سکوت منقضی شود، اپراتور را با یک ۴۰۹ بی‌توضیح تنها می‌گذارد. */ - it('reports expiry to the parent exactly once it lapses', () => { - const onExpired = vi.fn(); - render(); - - expect(onExpired).not.toHaveBeenCalled(); - - act(() => { vi.advanceTimersByTime(3000); }); - - expect(onExpired).toHaveBeenCalled(); - expect(screen.getByText('مهلت تمام شد')).toBeInTheDocument(); - }); - - it('switches to the urgent style under a minute', () => { - const { container } = render( {}} />); - - expect(container.querySelector('.badge.red')).not.toBeNull(); - }); -}); diff --git a/assets/admin/components/HoldCountdown.tsx b/assets/admin/components/HoldCountdown.tsx deleted file mode 100644 index 87dc7285..00000000 --- a/assets/admin/components/HoldCountdown.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useEffect, useState } from 'react'; - -interface Props { - expiresAt: number; - onExpired: () => void; -} - -/** - * شمارش معکوس مهلت رزرو موقت. - * - * بدون این، اپراتور نمی‌داند چقدر وقت دارد و رزرو در سکوت منقضی می‌شود — بعد کلیک - * «ثبت» یک ۴۰۹ می‌گیرد که هیچ‌جا توضیحش را ندیده. - * - * مبنا `expires_at` سرور است، نه شمارنده‌ای که از لحظهٔ رندر شروع شود: ساعت مرورگر و - * تأخیر شبکه هر دو می‌توانند چند ثانیه اختلاف بسازند و آن چند ثانیه دقیقاً همان‌جایی - * است که رزرو از دست می‌رود. - */ -export default function HoldCountdown({ expiresAt, onExpired }: Props) { - const [remaining, setRemaining] = useState(() => expiresAt - Math.floor(Date.now() / 1000)); - - useEffect(() => { - const tick = () => { - const left = expiresAt - Math.floor(Date.now() / 1000); - setRemaining(left); - - if (left <= 0) onExpired(); - }; - - tick(); - const timer = setInterval(tick, 1000); - - return () => clearInterval(timer); - }, [expiresAt, onExpired]); - - if (remaining <= 0) { - return ( - - - مهلت تمام شد - - ); - } - - const minutes = Math.floor(remaining / 60); - const seconds = remaining % 60; - - // زیر یک دقیقه هشدار می‌گیرد؛ همان لحظه‌ای که اپراتور باید تصمیمش را بگیرد. - const urgent = remaining < 60; - - return ( - - - مهلت ثبت: {minutes}:{String(seconds).padStart(2, '0')} - - ); -} diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index f228db9b..efb9ee0c 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -86,9 +86,9 @@ const MODE_LABELS: Record = { }; /** - * حالت‌های قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: نوبت‌دهی منبع‌محور از - * تنظیمات پزشک برداشته شده و مسیر خودش (صفحهٔ «نوبت منبع‌محور» و تقویم هر منبع) را - * دارد. انتخابش برگشت‌ناپذیر بود و محیط را قفل می‌کرد. + * حالت‌های قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: انتخابش برگشت‌ناپذیر + * بود و محیط را قفل می‌کرد، پس از تنظیمات پزشک برداشته شد. صفحهٔ «نوبت منبع‌محور» هم + * در ۲۰۲۶-۰۸-۰۸ حذف شد؛ رکوردهای قدیمی فقط خواندنی اعلام می‌شوند (پایین همین فایل). */ const BOOKING_MODE_CARDS: ReadonlyArray = [ ['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'], diff --git a/assets/admin/hooks/useResourceBooking.ts b/assets/admin/hooks/useResourceBooking.ts index 2d64df87..740107f3 100644 --- a/assets/admin/hooks/useResourceBooking.ts +++ b/assets/admin/hooks/useResourceBooking.ts @@ -1,133 +1,13 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { api, ApiError, type ApiResponse } from '../lib/api'; +import { useQuery } from '@tanstack/react-query'; +import { api, type ApiResponse } from '../lib/api'; /** - * جستجوی وقت، رزرو موقت و ثبت نهایی در حالت منبع‌محور. + * بخش‌های زمانیِ یک نوبت. * - * سه عمل جدا هستند و باید جدا بمانند: بین «دیدن وقت» و «گرفتنش» صندلی هنوز آزاد است، - * و بین «گرفتن» و «ثبت» یک مهلت وجود دارد که اگر نگذرد، ظرفیت برای همیشه قفل می‌ماند. + * تا ۲۰۲۶-۰۸-۰۸ این فایل موتورِ صفحهٔ «نوبت منبع‌محور» هم بود — جستجوی وقت، رزرو + * موقت و ثبت نهایی. آن صفحه حذف شد و آن سه هوک با خودش رفتند. فقط خواندنِ + * بخش‌های نوبت ماند، چون کارت جزئیات نوبت و AppointmentSegmentsCard به آن وابسته‌اند. */ -export interface ResourceRef { - uuid: string; - name: string; -} - -export type SlotAssignment = Record; - -export interface AvailableSlot { - start: number; - end: number; - assignment: SlotAssignment; -} - -export interface PlanSegment { - sequence: number; - name: string; - offset_minutes: number; - duration_minutes: number; - patient_present: boolean; - requirements: { role: string; role_name: string; count: number }[]; -} - -export interface AvailabilityResult { - plan: { total_minutes: number; segments: PlanSegment[] }; - slots: AvailableSlot[]; - /** خالی بودن فهرست خطا نیست؛ این می‌گوید چرا خالی است. */ - reason: string | null; -} - -export interface HoldResult { - hold_uuid: string; - starts_at: number; - ends_at: number; - expires_at: number; - confirmed: boolean; - assignment: SlotAssignment; -} - -export const REASON_LABELS: Record = { - no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگ‌تر کنید یا سرویس دیگری را امتحان کنید.', - no_working_hours: 'در این بازه ساعت کاری تعریف نشده است.', - no_eligible_resource: 'هیچ منبعی شرایط بخش‌های این خدمت را ندارد.', -}; - -function fail(e: unknown, fallback: string) { - toast.error(e instanceof ApiError ? e.message : fallback); -} - -export function useAvailabilitySearch( - params: { serviceUuid: string; branchUuid: string; from: number; to: number; stepMinutes?: number }, - enabled: boolean, -) { - const query = useQuery({ - queryKey: ['resource-availability', params], - queryFn: () => - api.post>('/api/v1/appointment-availability', { - service_uuid: params.serviceUuid, - branch_uuid: params.branchUuid, - from: params.from, - to: params.to, - ...(params.stepMinutes ? { step_minutes: params.stepMinutes } : {}), - }), - enabled: enabled && !!params.serviceUuid && !!params.branchUuid, - retry: false, - }); - - return { - result: query.data?.data, - loading: query.isFetching, - error: query.error, - refetch: query.refetch, - }; -} - -export function useHold() { - const create = useMutation({ - mutationFn: (body: { - service_uuid: string; - branch_uuid: string; - start: number; - assignment: Record; - item_uuids?: string[]; - patient_gender?: string; - }) => api.post>('/api/v1/appointment-hold', body), - // ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور دقیقاً همین را می‌گوید. - onError: (e) => fail(e, 'گرفتن این زمان ناموفق بود'), - }); - - const release = useMutation({ - mutationFn: (uuid: string) => api.delete>(`/api/v1/appointment-hold/${uuid}`), - onSuccess: () => toast.success('رزرو موقت آزاد شد'), - onError: (e) => fail(e, 'آزادسازی ناموفق بود'), - }); - - const confirm = useMutation({ - mutationFn: (body: { hold_uuid: string; doctor_uuid: string; patient_uuid?: string }) => - api.post>('/api/v1/appointment-confirm', body), - onSuccess: () => toast.success('نوبت ثبت شد'), - onError: (e) => fail(e, 'ثبت نهایی ناموفق بود'), - }); - - /** - * جابه‌جایی: رزرو موقتِ زمان تازه از قبل گرفته شده و اینجا فقط تأیید می‌شود. - * - * عمداً از `confirm` جداست — سرور در همین یک درخواست زمان قدیم را هم آزاد می‌کند، پس - * لغو دستی پیش از رزرو یعنی پنجره‌ای که در آن بیمار اصلاً نوبت ندارد. - */ - const rebook = useMutation({ - mutationFn: ({ appointmentUuid, holdUuid }: { appointmentUuid: string; holdUuid: string }) => - api.post>( - `/api/v1/appointment/${appointmentUuid}/rebook`, - { hold_uuid: holdUuid }, - ), - onSuccess: () => toast.success('نوبت جابه‌جا شد'), - onError: (e) => fail(e, 'جابه‌جایی نوبت ناموفق بود'), - }); - - return { create, release, confirm, rebook }; -} - export interface AppointmentSegmentRow { sequence: number; name: string; diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 2d5471d7..20d77252 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -472,18 +472,7 @@ export default function AppointmentsPage() { - {/* رزرو منبع‌محور مسیر جداست چون جستجویش از تقاطع تقویم منابع می‌آید، نه از - اسلات‌های یک پزشک؛ ادغامشان در یک فرم، هر دو را گیج می‌کرد. */} - {!isRepresentation && canCreateAppt && ( - - )} - + {/* دکمهٔ «نوبت منبع‌محور» و صفحه‌اش در ۲۰۲۶-۰۸-۰۸ حذف شدند. */} {!isRepresentation && canCreateAppt && ( - - - {error && ( -
- {error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'} -
- )} - - {result && ( -
-
- - مدت نوبت: {formatNumber(result.plan.total_minutes)} دقیقه - - - {formatNumber(result.plan.segments.length)} بخش · {formatNumber(result.slots.length)} وقت پیدا شد - -
- - {/* فهرست خالی خطا نیست؛ دلیلش را می‌گوییم تا کاربر حدس نزند. */} - {result.slots.length === 0 && reasonText && ( - {reasonText} - )} -
- )} - - {result && result.slots.length > 0 && ( -
- - - - - - - - - - {result.slots.slice(0, 100).map((slot) => ( - - - - - - - ))} - -
تاریخساعتمنابع پیشنهادی -
{formatDate(slot.start)} - {timeOf(slot.start)} – {timeOf(slot.end)} - - {Object.values(slot.assignment) - .flat() - .map((r) => r.name) - .join('، ')} - - -
-
- )} - - {pickedSlot && ( -
-
-

- {formatDate(pickedSlot.start)} · {timeOf(pickedSlot.start)} -

- {hold && !expired && } - {expired && ( - - - مهلت تمام شد — دوباره جستجو کنید - - )} -
- -
- {Object.entries(pickedSlot.assignment).map(([role, resources]) => ( -
- {role} -
- - setPicked((prev) => ({ ...(prev ?? {}), [role]: [String(v ?? '')] })) - } - options={optionsFor(role)} - isDisabled={hold !== null} - /> -
-
- ))} -
- - - فهرست هر نقش فقط منابعی است که در همین زمان آزادند؛ تخصیص منابع به بیمار - نمایش داده نمی‌شود. - - - {/* در جابه‌جایی پزشک عوض نمی‌شود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */} - {!rebookUuid && ( -
- - setDoctorUuid(String(v ?? ''))} - options={doctors.map((d) => ({ value: d.uuid, label: d.name }))} - placeholder="انتخاب پزشک" - /> -
- )} - -
- {hold === null ? ( - - ) : ( - <> - {rebookUuid ? ( - - ) : ( - - )} - - - - )} -
-
- )} - - ); -}