refactor: remove ResourceBookingPage and related components; update references in AppointmentsPage and ScheduleSection
This commit is contained in:
@@ -79,7 +79,6 @@ import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
|
|||||||
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
|
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
|
||||||
import PatientsListPage from './pages/PatientsListPage';
|
import PatientsListPage from './pages/PatientsListPage';
|
||||||
import InventoryPage from './pages/InventoryPage';
|
import InventoryPage from './pages/InventoryPage';
|
||||||
import ResourceBookingPage from './pages/ResourceBookingPage';
|
|
||||||
import ResourcesPage from './pages/ResourcesPage';
|
import ResourcesPage from './pages/ResourcesPage';
|
||||||
import ResourceTypesPage from './pages/ResourceTypesPage';
|
import ResourceTypesPage from './pages/ResourceTypesPage';
|
||||||
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
|
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
|
||||||
@@ -301,7 +300,6 @@ export default function App() {
|
|||||||
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['appointment_settings', 'view']}><PracticeDomainSettingsPage /></RoleRoute>} />
|
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['appointment_settings', 'view']}><PracticeDomainSettingsPage /></RoleRoute>} />
|
||||||
<Route path="practice-domains" element={<RoleRoute roles={['admin']}><AdminPracticeDomainsPage /></RoleRoute>} />
|
<Route path="practice-domains" element={<RoleRoute roles={['admin']}><AdminPracticeDomainsPage /></RoleRoute>} />
|
||||||
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
||||||
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
|
|
||||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcesPage /></RoleRoute>} />
|
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><SkillsPage /></RoleRoute>} />
|
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><SkillsPage /></RoleRoute>} />
|
||||||
|
|||||||
@@ -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(<HoldCountdown expiresAt={nowSeconds() + 125} onExpired={() => {}} />);
|
|
||||||
|
|
||||||
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(<HoldCountdown expiresAt={nowSeconds() + 2} onExpired={onExpired} />);
|
|
||||||
|
|
||||||
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(<HoldCountdown expiresAt={nowSeconds() + 30} onExpired={() => {}} />);
|
|
||||||
|
|
||||||
expect(container.querySelector('.badge.red')).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 (
|
|
||||||
<span className="badge red">
|
|
||||||
<span className="bdot" />
|
|
||||||
مهلت تمام شد
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(remaining / 60);
|
|
||||||
const seconds = remaining % 60;
|
|
||||||
|
|
||||||
// زیر یک دقیقه هشدار میگیرد؛ همان لحظهای که اپراتور باید تصمیمش را بگیرد.
|
|
||||||
const urgent = remaining < 60;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={urgent ? 'badge red' : 'badge amber'}>
|
|
||||||
<span className="bdot" />
|
|
||||||
مهلت ثبت: {minutes}:{String(seconds).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -86,9 +86,9 @@ const MODE_LABELS: Record<BookingMeta['booking_mode'], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* حالتهای قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: نوبتدهی منبعمحور از
|
* حالتهای قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: انتخابش برگشتناپذیر
|
||||||
* تنظیمات پزشک برداشته شده و مسیر خودش (صفحهٔ «نوبت منبعمحور» و تقویم هر منبع) را
|
* بود و محیط را قفل میکرد، پس از تنظیمات پزشک برداشته شد. صفحهٔ «نوبت منبعمحور» هم
|
||||||
* دارد. انتخابش برگشتناپذیر بود و محیط را قفل میکرد.
|
* در ۲۰۲۶-۰۸-۰۸ حذف شد؛ رکوردهای قدیمی فقط خواندنی اعلام میشوند (پایین همین فایل).
|
||||||
*/
|
*/
|
||||||
const BOOKING_MODE_CARDS: ReadonlyArray<readonly [BookingMeta['booking_mode'], string, string]> = [
|
const BOOKING_MODE_CARDS: ReadonlyArray<readonly [BookingMeta['booking_mode'], string, string]> = [
|
||||||
['slot', 'نوبتدهی اسلاتی', 'شما بازههای کاری و «مدت هر نوبت» را مشخص میکنید؛ سیستم بازه را به نوبتهای هماندازه تقسیم میکند. مناسب ویزیتهای با زمان یکسان.'],
|
['slot', 'نوبتدهی اسلاتی', 'شما بازههای کاری و «مدت هر نوبت» را مشخص میکنید؛ سیستم بازه را به نوبتهای هماندازه تقسیم میکند. مناسب ویزیتهای با زمان یکسان.'],
|
||||||
|
|||||||
@@ -1,133 +1,13 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { api, type ApiResponse } from '../lib/api';
|
||||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* جستجوی وقت، رزرو موقت و ثبت نهایی در حالت منبعمحور.
|
* بخشهای زمانیِ یک نوبت.
|
||||||
*
|
*
|
||||||
* سه عمل جدا هستند و باید جدا بمانند: بین «دیدن وقت» و «گرفتنش» صندلی هنوز آزاد است،
|
* تا ۲۰۲۶-۰۸-۰۸ این فایل موتورِ صفحهٔ «نوبت منبعمحور» هم بود — جستجوی وقت، رزرو
|
||||||
* و بین «گرفتن» و «ثبت» یک مهلت وجود دارد که اگر نگذرد، ظرفیت برای همیشه قفل میماند.
|
* موقت و ثبت نهایی. آن صفحه حذف شد و آن سه هوک با خودش رفتند. فقط خواندنِ
|
||||||
|
* بخشهای نوبت ماند، چون کارت جزئیات نوبت و AppointmentSegmentsCard به آن وابستهاند.
|
||||||
*/
|
*/
|
||||||
export interface ResourceRef {
|
|
||||||
uuid: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SlotAssignment = Record<string, ResourceRef[]>;
|
|
||||||
|
|
||||||
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<string, string> = {
|
|
||||||
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<ApiResponse<AvailabilityResult>>('/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<string, string[]>;
|
|
||||||
item_uuids?: string[];
|
|
||||||
patient_gender?: string;
|
|
||||||
}) => api.post<ApiResponse<HoldResult>>('/api/v1/appointment-hold', body),
|
|
||||||
// ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور دقیقاً همین را میگوید.
|
|
||||||
onError: (e) => fail(e, 'گرفتن این زمان ناموفق بود'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const release = useMutation({
|
|
||||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/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<ApiResponse<{ uuid: string }>>('/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<ApiResponse<{ appointment_uuid: string; starts_at: number }>>(
|
|
||||||
`/api/v1/appointment/${appointmentUuid}/rebook`,
|
|
||||||
{ hold_uuid: holdUuid },
|
|
||||||
),
|
|
||||||
onSuccess: () => toast.success('نوبت جابهجا شد'),
|
|
||||||
onError: (e) => fail(e, 'جابهجایی نوبت ناموفق بود'),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { create, release, confirm, rebook };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppointmentSegmentRow {
|
export interface AppointmentSegmentRow {
|
||||||
sequence: number;
|
sequence: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -472,18 +472,7 @@ export default function AppointmentsPage() {
|
|||||||
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* رزرو منبعمحور مسیر جداست چون جستجویش از تقاطع تقویم منابع میآید، نه از
|
{/* دکمهٔ «نوبت منبعمحور» و صفحهاش در ۲۰۲۶-۰۸-۰۸ حذف شدند. */}
|
||||||
اسلاتهای یک پزشک؛ ادغامشان در یک فرم، هر دو را گیج میکرد. */}
|
|
||||||
{!isRepresentation && canCreateAppt && (
|
|
||||||
<button
|
|
||||||
className="btn secondary sm"
|
|
||||||
onClick={() => navigate('/admin/resource-booking')}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
|
||||||
>
|
|
||||||
نوبت منبعمحور
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isRepresentation && canCreateAppt && (
|
{!isRepresentation && canCreateAppt && (
|
||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
||||||
import HoldCountdown from '../components/HoldCountdown';
|
|
||||||
import { formatDate, formatNumber } from '../lib/utils';
|
|
||||||
import { useAddresses } from '../hooks/useAddresses';
|
|
||||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
|
||||||
import {
|
|
||||||
REASON_LABELS,
|
|
||||||
useAvailabilitySearch,
|
|
||||||
useHold,
|
|
||||||
type AvailableSlot,
|
|
||||||
type HoldResult,
|
|
||||||
} from '../hooks/useResourceBooking';
|
|
||||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
const DAY = 86400;
|
|
||||||
|
|
||||||
function timeOf(ts: number): string {
|
|
||||||
return new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* رزرو نوبت در حالت منبعمحور.
|
|
||||||
*
|
|
||||||
* سه مرحلهٔ جدا و عمداً جدا: جستجو → گرفتن موقت → ثبت نهایی. بین دومی و سومی یک مهلت
|
|
||||||
* هست و صفحه آن را با شمارش معکوس نشان میدهد؛ رزروی که در سکوت منقضی شود، اپراتور را
|
|
||||||
* با یک ۴۰۹ بیتوضیح تنها میگذارد.
|
|
||||||
*/
|
|
||||||
export default function ResourceBookingPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const { addresses } = useAddresses();
|
|
||||||
/**
|
|
||||||
* محل نوبتدهی از خودِ محیط میآید، نه از یک انتخابگر.
|
|
||||||
*
|
|
||||||
* منابع دامنهٔ شعبه ندارند: دستگاه و اتاق مالِ همین کلینیکاند و آن select همیشه یک
|
|
||||||
* گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود.
|
|
||||||
*/
|
|
||||||
const branchUuid = addresses[0]?.uuid ?? '';
|
|
||||||
const { items: services } = useAllServiceItems();
|
|
||||||
const { create, release, confirm, rebook } = useHold();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* حالت جابهجایی — همان سه مرحله، با یک تفاوت در گام آخر.
|
|
||||||
*
|
|
||||||
* صفحهٔ جدا نساختیم چون جستجو و رزرو موقت دقیقاً هماناند؛ چیزی که فرق میکند فقط
|
|
||||||
* این است که در پایان بهجای «ثبت نوبت تازه»، نوبت موجود جابهجا میشود و زمان قدیم
|
|
||||||
* در همان درخواست آزاد میشود.
|
|
||||||
*/
|
|
||||||
const rebookUuid = params.get('rebook');
|
|
||||||
|
|
||||||
const [serviceUuid, setServiceUuid] = useState('');
|
|
||||||
const [days, setDays] = useState('7');
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
|
|
||||||
const [doctorUuid, setDoctorUuid] = useState('');
|
|
||||||
const [hold, setHold] = useState<HoldResult | null>(null);
|
|
||||||
const [expired, setExpired] = useState(false);
|
|
||||||
|
|
||||||
/** جایگزینی منبع per نقش، فقط برای همان زمانِ انتخابشده. */
|
|
||||||
const [picked, setPicked] = useState<Record<string, string[]> | null>(null);
|
|
||||||
const [pickedSlot, setPickedSlot] = useState<AvailableSlot | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* پزشکِ نوبت — ثبت نهایی بدونش ممکن نیست.
|
|
||||||
*
|
|
||||||
* منشی از اندپوینت احرازشده میگیرد تا فقط پزشکان تخصیصیافتهاش بیایند؛ همان
|
|
||||||
* قاعدهای که صفحهٔ نوبتها از قبل دارد.
|
|
||||||
*/
|
|
||||||
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
|
||||||
queryKey: ['booking-doctors'],
|
|
||||||
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const doctors = doctorsQuery.data?.data?.data ?? [];
|
|
||||||
|
|
||||||
const range = useMemo(() => {
|
|
||||||
const from = Math.floor(Date.now() / 1000);
|
|
||||||
return { from, to: from + Number(days) * DAY };
|
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
const { result, loading, error, refetch } = useAvailabilitySearch(
|
|
||||||
{ serviceUuid, branchUuid, from: range.from, to: range.to },
|
|
||||||
searching,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onExpired = useCallback(() => setExpired(true), []);
|
|
||||||
|
|
||||||
const chooseSlot = (slot: AvailableSlot) => {
|
|
||||||
setPickedSlot(slot);
|
|
||||||
setPicked(
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(slot.assignment).map(([role, resources]) => [role, resources.map((r) => r.uuid)]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* گزینههای جایگزین یک نقش: منابعی که در **همین زمان** پیشنهاد شدهاند.
|
|
||||||
*
|
|
||||||
* فهرست کاملِ منابع محیط اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد
|
|
||||||
* نبوده، و نشان دادنش یعنی اپراتور چیزی انتخاب کند که ۴۰۹ میگیرد.
|
|
||||||
*/
|
|
||||||
const optionsFor = (role: string): { value: string; label: string }[] =>
|
|
||||||
(pickedSlot?.assignment[role] ?? []).map((r) => ({ value: r.uuid, label: r.name }));
|
|
||||||
|
|
||||||
const takeHold = async () => {
|
|
||||||
if (!pickedSlot || !picked) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await create.mutateAsync({
|
|
||||||
service_uuid: serviceUuid,
|
|
||||||
branch_uuid: branchUuid,
|
|
||||||
start: pickedSlot.start,
|
|
||||||
assignment: picked,
|
|
||||||
});
|
|
||||||
|
|
||||||
setHold(created.data);
|
|
||||||
setExpired(false);
|
|
||||||
} catch (e) {
|
|
||||||
// ۴۰۹ یعنی همین لحظه کس دیگری گرفت. گفتنش کافی نیست: کاربر باید بلافاصله
|
|
||||||
// جایگزین ببیند، وگرنه باید دستی دوباره جستجو بزند و بختش را از نو امتحان کند.
|
|
||||||
if (e instanceof ApiError && e.status === 409) {
|
|
||||||
setPickedSlot(null);
|
|
||||||
setPicked(null);
|
|
||||||
await refetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fade-in">
|
|
||||||
<PageHeader
|
|
||||||
title={rebookUuid ? 'جابهجایی نوبت' : 'رزرو نوبت منبعمحور'}
|
|
||||||
description={
|
|
||||||
rebookUuid
|
|
||||||
? 'زمان تازه را انتخاب و نگه دارید؛ زمان قبلی در همان لحظهٔ جابهجایی آزاد میشود.'
|
|
||||||
: 'وقت آزاد از تقاطع تقویم منابع میآید؛ هر وقت با منابع پیشنهادی خودش نمایش داده میشود.'
|
|
||||||
}
|
|
||||||
backTo={rebookUuid ? `/admin/appointments/${rebookUuid}` : '/admin/appointments'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
||||||
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
|
|
||||||
<label>خدمت</label>
|
|
||||||
<SearchableSelect
|
|
||||||
value={serviceUuid}
|
|
||||||
onChange={(v) => {
|
|
||||||
setServiceUuid(String(v ?? ''));
|
|
||||||
setPickedSlot(null);
|
|
||||||
}}
|
|
||||||
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
|
||||||
placeholder="انتخاب خدمت"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field-block" style={{ minWidth: 160, margin: 0 }}>
|
|
||||||
<label>بازه</label>
|
|
||||||
<SearchableSelect
|
|
||||||
value={days}
|
|
||||||
onChange={(v) => setDays(String(v ?? '7'))}
|
|
||||||
options={[
|
|
||||||
{ value: '7', label: 'یک هفته' },
|
|
||||||
{ value: '30', label: 'یک ماه' },
|
|
||||||
{ value: '90', label: 'سه ماه' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn primary sm"
|
|
||||||
disabled={serviceUuid === '' || branchUuid === '' || loading}
|
|
||||||
onClick={() => setSearching(true)}
|
|
||||||
>
|
|
||||||
جستجوی وقت
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="card card-pad" style={{ marginBottom: 16, fontSize: 13, color: 'var(--danger)' }}>
|
|
||||||
{error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
|
|
||||||
<span>
|
|
||||||
مدت نوبت: <strong>{formatNumber(result.plan.total_minutes)}</strong> دقیقه
|
|
||||||
</span>
|
|
||||||
<span style={{ color: 'var(--text-2)' }}>
|
|
||||||
{formatNumber(result.plan.segments.length)} بخش · {formatNumber(result.slots.length)} وقت پیدا شد
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* فهرست خالی خطا نیست؛ دلیلش را میگوییم تا کاربر حدس نزند. */}
|
|
||||||
{result.slots.length === 0 && reasonText && (
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--warning)' }}>{reasonText}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && result.slots.length > 0 && (
|
|
||||||
<div style={{ overflowX: 'auto' }} className="card card-pad">
|
|
||||||
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse', minWidth: 520 }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
|
|
||||||
<th style={{ padding: '8px 6px', fontWeight: 500 }}>تاریخ</th>
|
|
||||||
<th style={{ padding: '8px 6px', fontWeight: 500 }}>ساعت</th>
|
|
||||||
<th style={{ padding: '8px 6px', fontWeight: 500 }}>منابع پیشنهادی</th>
|
|
||||||
<th style={{ padding: '8px 6px', fontWeight: 500 }} />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.slots.slice(0, 100).map((slot) => (
|
|
||||||
<tr
|
|
||||||
key={slot.start}
|
|
||||||
style={{
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
background: pickedSlot?.start === slot.start ? 'var(--primary-soft)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td style={{ padding: '8px 6px' }}>{formatDate(slot.start)}</td>
|
|
||||||
<td style={{ padding: '8px 6px' }} dir="ltr">
|
|
||||||
{timeOf(slot.start)} – {timeOf(slot.end)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '8px 6px', color: 'var(--text-2)' }}>
|
|
||||||
{Object.values(slot.assignment)
|
|
||||||
.flat()
|
|
||||||
.map((r) => r.name)
|
|
||||||
.join('، ')}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '8px 6px' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary sm"
|
|
||||||
disabled={hold !== null}
|
|
||||||
onClick={() => chooseSlot(slot)}
|
|
||||||
>
|
|
||||||
انتخاب
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pickedSlot && (
|
|
||||||
<div className="card card-pad" style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<h3 style={{ fontSize: 15, margin: 0 }}>
|
|
||||||
{formatDate(pickedSlot.start)} · <span dir="ltr">{timeOf(pickedSlot.start)}</span>
|
|
||||||
</h3>
|
|
||||||
{hold && !expired && <HoldCountdown expiresAt={hold.expires_at} onExpired={onExpired} />}
|
|
||||||
{expired && (
|
|
||||||
<span className="badge red">
|
|
||||||
<span className="bdot" />
|
|
||||||
مهلت تمام شد — دوباره جستجو کنید
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{Object.entries(pickedSlot.assignment).map(([role, resources]) => (
|
|
||||||
<div key={role} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ fontSize: 13, minWidth: 90, color: 'var(--text-2)' }}>{role}</span>
|
|
||||||
<div style={{ minWidth: 220 }}>
|
|
||||||
<SearchableSelect
|
|
||||||
value={picked?.[role]?.[0] ?? resources[0]?.uuid ?? ''}
|
|
||||||
onChange={(v) =>
|
|
||||||
setPicked((prev) => ({ ...(prev ?? {}), [role]: [String(v ?? '')] }))
|
|
||||||
}
|
|
||||||
options={optionsFor(role)}
|
|
||||||
isDisabled={hold !== null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
||||||
فهرست هر نقش فقط منابعی است که در همین زمان آزادند؛ تخصیص منابع به بیمار
|
|
||||||
نمایش داده نمیشود.
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* در جابهجایی پزشک عوض نمیشود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */}
|
|
||||||
{!rebookUuid && (
|
|
||||||
<div className="field-block" style={{ maxWidth: 280, margin: 0 }}>
|
|
||||||
<label>پزشک نوبت</label>
|
|
||||||
<SearchableSelect
|
|
||||||
value={doctorUuid}
|
|
||||||
onChange={(v) => setDoctorUuid(String(v ?? ''))}
|
|
||||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
|
||||||
placeholder="انتخاب پزشک"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
{hold === null ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn primary"
|
|
||||||
disabled={create.isPending}
|
|
||||||
onClick={takeHold}
|
|
||||||
>
|
|
||||||
نگهداشتن این زمان
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{rebookUuid ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn primary"
|
|
||||||
disabled={expired || rebook.isPending}
|
|
||||||
onClick={async () => {
|
|
||||||
await rebook.mutateAsync({
|
|
||||||
appointmentUuid: rebookUuid,
|
|
||||||
holdUuid: hold.hold_uuid,
|
|
||||||
});
|
|
||||||
navigate(`/admin/appointments/${rebookUuid}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
جابهجایی به این زمان
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn primary"
|
|
||||||
disabled={expired || doctorUuid === '' || confirm.isPending}
|
|
||||||
onClick={async () => {
|
|
||||||
await confirm.mutateAsync({ hold_uuid: hold.hold_uuid, doctor_uuid: doctorUuid });
|
|
||||||
navigate('/admin/appointments');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ثبت نهایی نوبت
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
disabled={release.isPending}
|
|
||||||
onClick={async () => {
|
|
||||||
await release.mutateAsync(hold.hold_uuid);
|
|
||||||
setHold(null);
|
|
||||||
setPickedSlot(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
آزادکردن
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user