diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index d7fd25b7..14dceb49 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -75,6 +75,7 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; +import ResourceBookingPage from './pages/ResourceBookingPage'; import PriceListsPage from './pages/PriceListsPage'; import ResourceUtilizationPage from './pages/ResourceUtilizationPage'; import PlanAccuracyPage from './pages/PlanAccuracyPage'; @@ -311,6 +312,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/HoldCountdown.test.tsx b/assets/admin/components/HoldCountdown.test.tsx new file mode 100644 index 00000000..79d7554c --- /dev/null +++ b/assets/admin/components/HoldCountdown.test.tsx @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000..87dc7285 --- /dev/null +++ b/assets/admin/components/HoldCountdown.tsx @@ -0,0 +1,56 @@ +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/hooks/useResourceBooking.ts b/assets/admin/hooks/useResourceBooking.ts new file mode 100644 index 00000000..17d39c3f --- /dev/null +++ b/assets/admin/hooks/useResourceBooking.ts @@ -0,0 +1,113 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; + +/** + * جستجوی وقت، رزرو موقت و ثبت نهایی در حالت منبع‌محور. + * + * سه عمل جدا هستند و باید جدا بمانند: بین «دیدن وقت» و «گرفتنش» صندلی هنوز آزاد است، + * و بین «گرفتن» و «ثبت» یک مهلت وجود دارد که اگر نگذرد، ظرفیت برای همیشه قفل می‌ماند. + */ +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, 'ثبت نهایی ناموفق بود'), + }); + + return { create, release, confirm }; +} diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 0b5d16b9..23ceb1da 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -795,6 +795,18 @@ export default function AppointmentsPage() { + {/* رزرو منبع‌محور مسیر جداست چون جستجویش از تقاطع تقویم منابع می‌آید، نه از + اسلات‌های یک پزشک؛ ادغامشان در یک فرم، هر دو را گیج می‌کرد. */} + {!isRepresentation && canCreateAppt && ( + + )} + {!isRepresentation && canCreateAppt && ( + + + {error && ( +
+ {error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'} +
+ )} + + {result && ( +
+
+ + مدت نوبت: {result.plan.total_minutes} دقیقه + + + {result.plan.segments.length} بخش · {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} + /> +
+
+ ))} +
+ + + فهرست هر نقش فقط منابعی است که در همین زمان آزادند؛ تخصیص منابع به بیمار + نمایش داده نمی‌شود. + + +
+ + setDoctorUuid(String(v ?? ''))} + options={doctors.map((d) => ({ value: d.uuid, label: d.name }))} + placeholder="انتخاب پزشک" + /> +
+ +
+ {hold === null ? ( + + ) : ( + <> + + + + + )} +
+
+ )} + + ); +} diff --git a/docs/new_feture/taskes/task-06-availability-engine/checklist.md b/docs/new_feture/taskes/task-06-availability-engine/checklist.md index 4aa3462e..e7f45e24 100644 --- a/docs/new_feture/taskes/task-06-availability-engine/checklist.md +++ b/docs/new_feture/taskes/task-06-availability-engine/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۶ (موتور جستجوی وقت چندمنبعی) -**وضعیت کلی:** ✅ بک‌اند، موتور، کارایی، مستندات و UI انتخاب حالت (جریان رزرو ⏳ با مقصد) · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده — موتور، کارایی، مستندات، انتخاب حالت و جریان رزرو · **آخرین بازبینی:** — قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -67,10 +67,10 @@ | ۴.۱ | انتخاب حالت `resource` + گام | ⚠️ | حالت سوم و «گام جستجوی وقت» به `ScheduleSection` اضافه شد. انتخابگر **استراتژی** ساخته نشد چون استراتژی‌ای در بک‌اند وجود ندارد (ردیف ۱.۲) — منوی خالی بدتر از نبودنش است | | ۴.۲ | چک‌لیست پیش از ارتقا با ✓/✗ | ✅ | ⭐ «حداقل یک منبع فعال» و «حداقل یک سرویس با بخش» با لینک اصلاح؛ همان شرطی که بک‌اند هم اعمال می‌کند | | ۴.۳ | تأیید برگشت‌ناپذیری | ✅ | `ConfirmDialog` موجود، حالا با برچسب درست هر سه حالت | -| ۴.۴ | جدول وقت‌ها با ستون «منابع پیشنهادی» | ⏳ | جریان **رزرو** منبع‌محور در پنل ساخته نشد؛ `POST /appointment-availability` و `assignment` از API کامل‌اند. مقصد: پاس جریان رزرو | -| ۴.۵ | عوض کردن یک منبع → اعتبارسنجی همان زمان | ⏳ | با ۴.۴ یک بسته است | -| ۴.۶ | `reason` خالی‌بودن با پیام فارسی | ⏳ | با ۴.۴ یک بسته است | -| ۴.۷ | `assignment` به بیمار نمایش داده نمی‌شود | ⏳ | با ۴.۴ یک بسته است | +| ۴.۴ | جدول وقت‌ها با ستون «منابع پیشنهادی» | ✅ | `ResourceBookingPage` — تاریخ، ساعت، منابع، انتخاب | +| ۴.۵ | عوض کردن یک منبع → گزینه‌های **همان زمان** | ✅ | ⭐ فهرست هر نقش فقط منابعی است که موتور برای همان زمان داده؛ فهرست کامل شعبه یعنی انتخابی که ۴۰۹ می‌گیرد | +| ۴.۶ | `reason` خالی‌بودن با پیام فارسی | ✅ | `REASON_LABELS` — «بازه را بزرگ‌تر کنید یا شعبهٔ دیگری را امتحان کنید» | +| ۴.۷ | `assignment` به بیمار نمایش داده نمی‌شود | ✅ | صفحه پنل‌محور است و همان‌جا هم نوشته شده | | ۴.۸ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` | | ۴.۹ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | | ۴.۱۰ | RTL و موبایل | ✅ | کارت‌های حالت روی موبایل تک‌ستونه می‌شوند | diff --git a/docs/new_feture/taskes/task-07-hold-and-book/checklist.md b/docs/new_feture/taskes/task-07-hold-and-book/checklist.md index 9a71c9d7..e88b3610 100644 --- a/docs/new_feture/taskes/task-07-hold-and-book/checklist.md +++ b/docs/new_feture/taskes/task-07-hold-and-book/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۷ (رزرو موقت و ثبت نهایی چندمنبعی) -**وضعیت کلی:** ✅ بک‌اند، تضمین دیتابیسی و مستندات تکمیل (UI ⏳) · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ بک‌اند، تضمین دیتابیسی، مستندات و جریان رزرو (چند مورد UI ⏳ با مقصد) · **آخرین بازبینی:** — قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -70,16 +70,18 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | تایمر شمارش معکوس hold در UI رزرو | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۲ | خطای `409` با پیام «این ساعت همین لحظه رزرو شد» + **لیست جایگزین خودکار** | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۴ | مسدودسازی موردی منبع از صفحهٔ منابع | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۵ | تفکیک «مسدودسازی موردی» (occupancy) از «بلندمدت» (exception) در UI روشن است | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۶ | هیچ رنگ/شعاع hard-code | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۷ | دارک‌مود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۸ | RTL و موبایل | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۹ | همهٔ رشته‌ها فارسی | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | -| ۴.۱۰ | `AppointmentDetailPage` بخش بخش‌های نوبت (فقط حالت `resource`) | ⏳ | UI این تسک ساخته نشد — چهار اندپوینت کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI رزرو چندمنبعی | +| ۴.۱ | تایمر شمارش معکوس hold | ✅ | ⭐ `HoldCountdown` — مبنا `expires_at` سرور است نه شمارندهٔ مرورگر؛ زیر یک دقیقه هشدار می‌شود. سه تست | +| ۴.۲ | خطای `409` با لیست جایگزین | ⚠️ | پیام سرور («این ساعت همین لحظه رزرو شد») نمایش داده می‌شود؛ **لیست جایگزین خودکار** ساخته نشد — کاربر باید دوباره جستجو بزند | +| ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | ⏳ | جریان جابه‌جایی نوبت در پنل ساخته نشد؛ `rebook` از API کامل است | +| ۴.۴ | مسدودسازی موردی منبع از صفحهٔ منابع | ⏳ | مقصد: پاس بعدی صفحهٔ منابع | +| ۴.۵ | تفکیک «مسدودسازی موردی» از «بلندمدت» در UI | ⏳ | با ۴.۴ یک بسته است | +| ۴.۶ | هیچ رنگ/شعاع hard-code | ✅ | | +| ۴.۷ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | +| ۴.۸ | RTL و موبایل | ✅ | جدول وقت‌ها اسکرول افقی داخلی دارد | +| ۴.۹ | همهٔ رشته‌ها فارسی | ✅ | | +| ۴.۱۰ | بخش‌های نوبت در `AppointmentDetailPage` | ⏳ | کارت فاکتور اضافه شد (تسک ۰۸) ولی بخش‌های نوبت نه | +| ۴.۱۱ | جریان سه‌مرحله‌ای رزرو | ✅ | ⭐ `ResourceBookingPage`: جستجو → نگه‌داشتن → ثبت، با آزادسازی صریح | +| ۴.۱۲ | انتخاب پزشک پیش از ثبت | ✅ | ثبت نهایی بدون پزشک ممکن نیست؛ دکمه تا انتخاب نشدن غیرفعال است | ## ۵. تست