diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx new file mode 100644 index 00000000..d1e8a22c --- /dev/null +++ b/assets/admin/components/appointments/ResourceBookingModal.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../../lib/api'; +import ResourceBookingModal from './ResourceBookingModal'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; + +const resource = { + uuid: 'r-laser', + name: 'لیزر CO2', + address_uuid: 'addr-1', +} as never; + +/** یکی از این دو وقت مالِ همین منبع است و دیگری مالِ دستگاه دیگری. */ +const slots = [ + { start: 1783859400, end: 1783861200, assignment: { device: [{ uuid: 'r-laser', name: 'لیزر CO2' }] } }, + { start: 1783863000, end: 1783864800, assignment: { device: [{ uuid: 'r-other', name: 'لیزر دیگر' }] } }, +]; + +beforeEach(() => { + get.mockReset(); + post.mockReset(); + get.mockImplementation((url: string) => { + if (url.includes('/services')) { + return Promise.resolve({ success: true, data: [ + { service_uuid: 's-1', service_name: 'لیزر صورت', active: true, effective_duration_minutes: 30 }, + { service_uuid: 's-2', service_name: 'سرویس غیرفعال', active: false, effective_duration_minutes: 20 }, + ] }); + } + if (url.includes('/my/clinic-doctors')) { + return Promise.resolve({ success: true, data: { data: [{ uuid: 'd-1', name: 'دکتر مرادی' }] } }); + } + return Promise.resolve({ success: true, data: [] }); + }); + post.mockImplementation((url: string) => { + if (url.includes('appointment-availability')) { + return Promise.resolve({ success: true, data: { plan: { total_minutes: 30, segments: [] }, slots, reason: null } }); + } + if (url.includes('appointment-hold')) { + return Promise.resolve({ success: true, data: { hold_uuid: 'h-1', assignment: {} } }); + } + return Promise.resolve({ success: true, data: { uuid: 'appt-1' } }); + }); +}); + +describe('ResourceBookingModal', () => { + /** فقط سرویس‌هایی که خودِ منبع ارائه می‌دهد — سرور هم بقیه را با ۴۲۲ رد می‌کند. */ + it('فقط سرویس‌های فعالِ همین منبع را می‌آورد', async () => { + renderWithProviders( + , + ); + + expect(await screen.findByText('ثبت نوبت — لیزر CO2')).toBeInTheDocument(); + const called = get.mock.calls.map((c: any[]) => c[0]); + expect(called.some((u: string) => u.includes('/api/v1/resource/r-laser/services'))).toBe(true); + }); + + /** + * هستهٔ منبع‌محور بودن: موتور خدمت‌محور جواب می‌دهد، پس وقتی که این منبع در آن + * پیشنهاد نشده باید حذف شود — نشان دادنش یعنی اپراتور چیزی بگیرد که ۴۰۹ می‌شود. + */ + it('فقط وقت‌هایی را نشان می‌دهد که موتور همین منبع را در آن‌ها داده', async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await screen.findByText('ثبت نوبت — لیزر CO2'); + await user.click(await screen.findByText('سرویس را انتخاب کنید')); + await user.click(await screen.findByText(/لیزر صورت/)); + + await waitFor(() => expect(screen.getByText(/زمان‌های خالی این منبع/)).toBeInTheDocument()); + + // وقتِ منبعِ دیگر (۱۷۸۳۸۶۳۰۰۰) نباید دکمه‌ای داشته باشد. + const buttons = screen.getAllByRole('button').map((b) => b.textContent ?? ''); + const timeButtons = buttons.filter((t) => /\d{2}:\d{2}/.test(t)); + expect(timeButtons).toHaveLength(1); + }); + + /** پزشک مسئول اجباری است: `confirm` بدون آن اصلاً کار نمی‌کند. */ + it('تا پزشک مسئول انتخاب نشود، ثبت غیرفعال است', async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await screen.findByText('ثبت نوبت — لیزر CO2'); + expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); + + await user.click(await screen.findByText('سرویس را انتخاب کنید')); + await user.click(await screen.findByText(/لیزر صورت/)); + await waitFor(() => expect(screen.getByText(/زمان‌های خالی این منبع/)).toBeInTheDocument()); + + const timeBtn = screen.getAllByRole('button').find((b) => /\d{2}:\d{2}/.test(b.textContent ?? '')); + await user.click(timeBtn!); + + // زمان انتخاب شد ولی پزشک نه → هنوز غیرفعال. + expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); + expect(await screen.findByText('پزشک انجام‌دهنده')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/appointments/ResourceBookingModal.tsx b/assets/admin/components/appointments/ResourceBookingModal.tsx new file mode 100644 index 00000000..d09cd36f --- /dev/null +++ b/assets/admin/components/appointments/ResourceBookingModal.tsx @@ -0,0 +1,227 @@ +import React, { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import Modal from '../ui/Modal'; +import SearchableSelect from '../ui/SearchableSelect'; +import { api, type ApiResponse } from '../../lib/api'; +import { formatNumber } from '../../lib/utils'; +import { + REASON_LABELS, + useAvailabilitySearch, + useHold, + type AvailableSlot, +} from '../../hooks/useResourceBooking'; +import type { ClinicResource, ResourceServiceOffering } from '../../types'; + +const DAY = 86400; + +function hhmm(ts: number): string { + const d = new Date(ts * 1000); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +/** نقشی از تخصیص که این منبع در آن پیشنهاد شده — `null` یعنی این وقت مالِ منبع دیگری است. */ +function roleHolding(slot: AvailableSlot, resourceUuid: string): string | null { + for (const [role, resources] of Object.entries(slot.assignment)) { + if (resources.some((r) => r.uuid === resourceUuid)) return role; + } + return null; +} + +/** + * ثبت نوبت برای یک منبع مشخص — سرویس‌های خودِ منبع، بعد زمان، بعد پزشک مسئول. + * + * موتور همان `appointment-availability → hold → confirm` است که از قبل هست؛ تنها + * تفاوت این است که جستجو خدمت‌محور جواب می‌دهد و ما نتیجه را به وقت‌هایی تنگ می‌کنیم + * که موتور در آن‌ها **همین منبع** را پیشنهاد داده. وقتی که منبع در آن آزاد نبوده اصلاً + * نشان داده نمی‌شود، وگرنه اپراتور چیزی را انتخاب می‌کند که در `hold` خطای ۴۰۹ می‌گیرد. + * + * پزشک مسئول اجباری است: هر نوبت در این سیستم پزشک دارد و `confirm` بدونش کار نمی‌کند. + */ +export default function ResourceBookingModal({ resource, onClose, onBooked }: { + resource: ClinicResource; + onClose: () => void; + onBooked: () => void; +}) { + const { create, confirm } = useHold(); + + const [serviceUuid, setServiceUuid] = useState(''); + const [days, setDays] = useState('7'); + const [pickedSlot, setPickedSlot] = useState(null); + const [doctorUuid, setDoctorUuid] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // سرویس‌های همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمی‌دهد نباید + // در فهرست بیاید — سرور هم همان را با ۴۲۲ رد می‌کند. + const offeringsQuery = useQuery>({ + queryKey: ['resource-services', resource.uuid], + queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/services`), + }); + const offerings = (offeringsQuery.data?.data ?? []).filter((o) => o.active); + + const doctorsQuery = useQuery>({ + 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 } = useAvailabilitySearch( + { serviceUuid, branchUuid: resource.address_uuid, from: range.from, to: range.to }, + !!serviceUuid, + ); + + const slots = useMemo( + () => (result?.slots ?? []).filter((s) => roleHolding(s, resource.uuid) !== null), + [result, resource.uuid], + ); + + const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null; + const canSubmit = !!serviceUuid && !!pickedSlot && !!doctorUuid && !submitting; + + const submit = async () => { + if (!pickedSlot) return; + setSubmitting(true); + + try { + // تخصیص همان چیزی است که موتور برای این وقت داده، با یک قید: نقشی که این منبع + // در آن آمده به خودش قفل می‌شود تا نوبت واقعاً روی همین دستگاه بنشیند. + const role = roleHolding(pickedSlot, resource.uuid); + const assignment = Object.fromEntries( + Object.entries(pickedSlot.assignment).map(([r, list]) => [ + r, + r === role ? [resource.uuid] : list.map((x) => x.uuid), + ]), + ); + + const held = await create.mutateAsync({ + service_uuid: serviceUuid, + branch_uuid: resource.address_uuid, + start: pickedSlot.start, + assignment, + }); + + await confirm.mutateAsync({ hold_uuid: held.data.hold_uuid, doctor_uuid: doctorUuid }); + onBooked(); + onClose(); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + } + > +
+ + {offeringsQuery.isLoading ? ( +
+ ) : offerings.length === 0 ? ( +

+ برای این منبع سرویسی تعریف نشده است — از تب «سرویس‌ها»ی همین منبع اضافه کنید. +

+ ) : ( + ({ + value: o.service_uuid, + label: o.effective_duration_minutes + ? `${o.service_name} · ${formatNumber(o.effective_duration_minutes)} دقیقه` + : o.service_name, + }))} + value={serviceUuid || null} + onChange={(v) => { setServiceUuid(v ? String(v) : ''); setPickedSlot(null); }} + placeholder="سرویس را انتخاب کنید" + height={40} + /> + )} +
+ + {serviceUuid && ( +
+ + { setDays(v ? String(v) : '7'); setPickedSlot(null); }} + height={40} + /> +
+ )} + + {serviceUuid && ( +
+ زمان‌های خالی این منبع + {loading ? ( +
در حال محاسبه…
+ ) : slots.length === 0 ? ( +

+ {reasonText ?? 'برای این منبع در این بازه وقت آزادی نیست — بازه را بزرگ‌تر کنید.'} +

+ ) : ( +
+ {slots.slice(0, 40).map((s) => { + const active = pickedSlot?.start === s.start; + return ( + + ); + })} +
+ )} +
+ )} + + {pickedSlot && ( +
+ + ({ value: d.uuid, label: d.name }))} + value={doctorUuid || null} + onChange={(v) => setDoctorUuid(v ? String(v) : '')} + placeholder="پزشک انجام‌دهنده" + height={40} + /> +

+ هر نوبت پزشک مسئول دارد؛ منبع می‌گوید کار روی چه دستگاهی انجام می‌شود. +

+
+ )} + + ); +} diff --git a/assets/admin/components/appointments/ResourceTimeline.test.tsx b/assets/admin/components/appointments/ResourceTimeline.test.tsx deleted file mode 100644 index 1a5675f3..00000000 --- a/assets/admin/components/appointments/ResourceTimeline.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { screen } from '@testing-library/react'; -import { renderWithProviders } from '../../test/utils'; -import ResourceTimeline from './ResourceTimeline'; -import type { ResourceTimelineLane } from '../../hooks/useResourceTimeline'; - -/** نیمه‌شبِ یک روز ثابت، تا موقعیت بلوک‌ها به ساعت اجرای تست وابسته نباشد. */ -const DAY_START = Math.floor(new Date(2026, 7, 5, 0, 0, 0).getTime() / 1000); -const at = (hour: number, minute = 0) => DAY_START + hour * 3600 + minute * 60; - -function lane(overrides: Partial = {}): ResourceTimelineLane { - return { - uuid: 'r-1', - name: 'لیزر دایود', - type_name: 'دستگاه لیزر', - address_name: 'شعبهٔ مرکزی', - capacity: 1, - shifts: [{ start_minute: 9 * 60, end_minute: 17 * 60 }], - shift_minutes: 480, - busy_minutes: 60, - free_minutes: 420, - items: [ - { - uuid: 'o-1', - starts_at: at(10), - ends_at: at(11), - status: 'booked', - segment_name: 'لیزر', - appointment_id: 47, - patient_name: 'زهرا احمدی', - appointment_uuid: 'appt-1', - appointment_status: 'confirmed', - }, - ], - ...overrides, - }; -} - -describe('ResourceTimeline', () => { - it('یک ردیف به‌ازای هر منبع با نام بیمار و بخش نشان می‌دهد', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('لیزر دایود')).toBeInTheDocument(); - expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument(); - expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument(); - }); - - it('بلوک به جزئیات همان نوبت لینک می‌دهد', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('زهرا احمدی · لیزر').closest('a')) - .toHaveAttribute('href', '/admin/appointments/appt-1'); - }); - - /** - * یک نوبت می‌تواند هم‌زمان اتاق و دستگاه را بگیرد؛ همین است که ظرفیت را تمام می‌کند و - * نمای پزشک‌محور نشانش نمی‌دهد. - */ - it('یک نوبت روی چند منبع، روی هر ردیف دیده می‌شود', () => { - const room = lane({ - uuid: 'r-2', - name: 'اتاق لیزر ۱', - type_name: 'اتاق', - items: [{ ...lane().items[0], uuid: 'o-2', segment_name: 'بی‌حسی موضعی' }], - }); - - renderWithProviders( - , - ); - - expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument(); - expect(screen.getByText('زهرا احمدی · بی‌حسی موضعی')).toBeInTheDocument(); - }); - - it('روزِ بدون شیفت و بدون نوبت را صریح می‌گوید', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('این روز شیفتی ندارد')).toBeInTheDocument(); - }); - - it('بدون هیچ منبعی به صفحهٔ تعریف منبع راه می‌دهد', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('تنظیمات ← منابع').closest('a')).toHaveAttribute('href', '/admin/resources'); - }); - - it('خطای سرور را نشان می‌دهد، نه تایم‌لاین خالی', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument(); - }); - - /** ظرفیت و وقت آزاد باید روی خودِ ردیف باشد، نه حدسِ کاربر از روی بلوک‌ها. */ - it('وقت آزاد هر ردیف را نشان می‌دهد', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('۴۲۰ دقیقه آزاد')).toBeInTheDocument(); - }); - - it('ردیفِ پرشده «تکمیل» می‌گوید، نه صفر دقیقه', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('تکمیل')).toBeInTheDocument(); - }); - - it('ظرفیت بیش از یک را کنار نوع منبع می‌آورد', () => { - renderWithProviders( - , - ); - - expect(screen.getByText(/ظرفیت ۳/)).toBeInTheDocument(); - }); -}); diff --git a/assets/admin/components/appointments/ResourceTimeline.tsx b/assets/admin/components/appointments/ResourceTimeline.tsx deleted file mode 100644 index ee3f2996..00000000 --- a/assets/admin/components/appointments/ResourceTimeline.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import React, { useMemo } from 'react'; -import { Link } from 'react-router-dom'; -import { formatNumber } from '../../lib/utils'; -import type { ResourceTimelineLane, ResourceTimelineItem } from '../../hooks/useResourceTimeline'; - -const HOUR = 60; -const LANE_HEIGHT = 46; -const LABEL_WIDTH = 168; - -function hhmm(timestamp: number): string { - const d = new Date(timestamp * 1000); - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; -} - -/** ساعتِ خط‌کش با ارقام فارسی — بقیهٔ پنل هم فارسی است. */ -function hourLabel(minute: number): string { - return String(Math.floor(minute / 60)).padStart(2, '0').replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]); -} - -function minuteOfDay(timestamp: number, dayStart: number): number { - return Math.round((timestamp - dayStart) / 60); -} - -/** - * بازهٔ ساعتی که نشان داده می‌شود: از شیفت‌ها و اشغال‌های همان روز درمی‌آید، نه ۰ تا ۲۴. - * - * نمایش کل شبانه‌روز یعنی کلینیکی که ۱۶ تا ۲۱ کار می‌کند، پنج‌ششمِ عرض صفحه‌اش خالی - * بماند و بلوک‌ها آن‌قدر باریک شوند که خوانده نشوند. - */ -function windowOf(lanes: ResourceTimelineLane[], dayStart: number): { from: number; to: number } { - let from = 24 * HOUR; - let to = 0; - - lanes.forEach((lane) => { - lane.shifts.forEach((s) => { - from = Math.min(from, s.start_minute); - to = Math.max(to, s.end_minute); - }); - lane.items.forEach((i) => { - from = Math.min(from, minuteOfDay(i.starts_at, dayStart)); - to = Math.max(to, minuteOfDay(i.ends_at, dayStart)); - }); - }); - - if (from >= to) return { from: 8 * HOUR, to: 20 * HOUR }; - - // به ساعت گرد می‌شود تا خط‌کش بالای نمودار عدد کامل نشان دهد. - return { from: Math.floor(from / HOUR) * HOUR, to: Math.ceil(to / HOUR) * HOUR }; -} - -/** - * تایم‌لاین منبع‌محورِ یک روز — یک ردیف به‌ازای هر منبع. - * - * نمای پزشک‌محور نمی‌گوید چرا ساعتی پر است؛ در مدل منبع‌محور یک نوبت می‌تواند هم‌زمان - * اتاق و دستگاه را بگیرد و همان است که ظرفیت را تمام می‌کند. اینجا هر بلوک یک ردیفِ - * اشغال است، پس یک نوبتِ چندبخشی روی چند ردیف دیده می‌شود. - */ -export default function ResourceTimeline({ lanes, dayStart, loading, error }: { - lanes: ResourceTimelineLane[]; - dayStart: number; - loading: boolean; - error: string | null; -}) { - const { from, to } = useMemo(() => windowOf(lanes, dayStart), [lanes, dayStart]); - const span = to - from; - - const hours = useMemo( - () => Array.from({ length: Math.floor(span / HOUR) + 1 }, (_, i) => from + i * HOUR), - [from, span], - ); - - if (loading) { - return
در حال بارگذاری...
; - } - - if (error) { - return
{error}
; - } - - if (lanes.length === 0) { - return ( -
- هنوز منبعی تعریف نشده است. -
- تنظیمات ← منابع -
- ); - } - - return ( -
-
- {/* خط‌کش ساعت */} -
-
-
- {hours.map((m, i) => ( - - {hourLabel(m)} - - ))} -
-
- - {lanes.map((lane) => ( -
-
- - {lane.name} - - - {lane.type_name} - {lane.capacity > 1 && <> · ظرفیت {formatNumber(lane.capacity)}} - - -
- -
- {/* شیفت کاری: پس‌زمینهٔ روشن‌تر، تا «تعطیل» از «آزاد» فرق کند */} - {lane.shifts.map((s, i) => ( -
- ))} - - {hours.map((m) => ( -
- ))} - - {lane.items.map((item) => ( - - ))} - - {lane.shifts.length === 0 && lane.items.length === 0 && ( - - این روز شیفتی ندارد - - )} -
-
- ))} -
-
- ); -} - -/** - * «چقدر از این ردیف هنوز جا دارد» — با ظرفیت حساب شده، نه پر/خالی. - * - * منبع ظرفیت‌۳ که دو نوبت هم‌زمان دارد هنوز آزاد است؛ بدون این عدد، کاربر از روی - * بلوک‌های تودرتو باید حدس بزند. - */ -function LaneLoad({ lane }: { lane: ResourceTimelineLane }) { - if (lane.shift_minutes === 0) { - return بدون شیفت; - } - - const full = lane.free_minutes === 0; - - return ( - - {full ? 'تکمیل' : `${formatNumber(lane.free_minutes)} دقیقه آزاد`} - - ); -} - -function Block({ item, dayStart, from, span }: { - item: ResourceTimelineItem; - dayStart: number; - from: number; - span: number; -}) { - const start = minuteOfDay(item.starts_at, dayStart); - const end = minuteOfDay(item.ends_at, dayStart); - const hold = item.status === 'hold'; - - const label = [item.patient_name, item.segment_name].filter(Boolean).join(' · '); - const title = `${hhmm(item.starts_at)} تا ${hhmm(item.ends_at)}${label ? ` — ${label}` : ''}`; - - const body = ( - - {label || hhmm(item.starts_at)} - - ); - - const style: React.CSSProperties = { - position: 'absolute', top: 8, height: 30, - right: `${((start - from) / span) * 100}%`, - width: `${Math.max(((end - start) / span) * 100, 1.2)}%`, - borderRadius: 6, textDecoration: 'none', - // رزرو موقت هنوز نوبت نیست: توپر نشان دادنش یعنی کاربر آن را قطعی بخواند. - background: hold ? 'var(--warning-bg)' : 'var(--primary)', - border: hold ? '1px dashed var(--warning)' : 'none', - }; - - if (!item.appointment_uuid) { - return
{body}
; - } - - return ( - - {body} - - ); -} diff --git a/assets/admin/hooks/useResourceTimeline.ts b/assets/admin/hooks/useResourceTimeline.ts deleted file mode 100644 index 81833215..00000000 --- a/assets/admin/hooks/useResourceTimeline.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { api, type ApiResponse } from '../lib/api'; - -export type ResourceTimelineItem = { - uuid: string; - starts_at: number; - ends_at: number; - status: 'booked' | 'hold'; - segment_name: string | null; - appointment_id: number | null; - patient_name: string | null; - appointment_uuid: string | null; - appointment_status: string | null; -}; - -export type ResourceTimelineLane = { - uuid: string; - name: string; - type_name: string; - address_name: string | null; - capacity: number; - shifts: { start_minute: number; end_minute: number }[]; - /** دقیقهٔ شیفت، دقیقهٔ پرشده (ظرفیت‌آگاه) و باقی‌ماندهٔ آزاد. */ - shift_minutes: number; - busy_minutes: number; - free_minutes: number; - items: ResourceTimelineItem[]; -}; - -export type ResourceTimelineDay = { - date: number; - day_of_week: number; - resources: ResourceTimelineLane[]; -}; - -/** - * «کدام منبع در این روز کِی گرفته است» — ورودی نمای منبع‌محورِ صفحهٔ نوبت‌ها. - * - * بازه‌ها از جدول اشغال می‌آیند نه از خودِ نوبت: آماده‌سازی و تمیزکاری دستگاه هم - * درونشان است و همان بازه‌ای است که موتور جستجو اشغال می‌بیند. - */ -export function useResourceTimeline(date: string, options: { addressUuid?: string; onlyBookable?: boolean } = {}) { - const params = new URLSearchParams({ date }); - if (options.addressUuid) params.set('address_uuid', options.addressUuid); - // منبعی که به هیچ سرویسی وصل نیست ردیفِ همیشه‌خالی است؛ در تایم‌لاین نوبت‌ها نمی‌آید. - if (options.onlyBookable) params.set('only_bookable', '1'); - - const query = useQuery({ - queryKey: ['resource-timeline', date, options.addressUuid ?? null, options.onlyBookable ?? false], - queryFn: () => api.get>(`/api/v1/resources/timeline?${params}`), - enabled: !!date, - }); - - return { - day: query.data?.data, - loading: query.isLoading, - error: query.isError ? ((query.error as Error)?.message || 'خطای نامشخص') : null, - }; -} diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index b8c00fb0..af92c17d 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -8,7 +8,7 @@ import { import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; -import type { Appointment } from '../types'; +import type { Appointment, ClinicResource } from '../types'; import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils'; import PriceInput from '../components/ui/PriceInput'; import Modal from '../components/ui/Modal'; @@ -23,12 +23,11 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import TurnsStatInfo from '../components/appointments/TurnsStatInfo'; import TurnsViewToggle from '../components/appointments/TurnsViewToggle'; import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle'; -import ResourceTimeline from '../components/appointments/ResourceTimeline'; -import { useResourceTimeline } from '../hooks/useResourceTimeline'; import DoctorTabs from '../components/appointments/DoctorTabs'; /** هیچ تبی فعال نیست — وقتی تب منبع انتخاب شده، نوار پزشکان نباید هایلایت داشته باشد. */ const NO_ACTIVE_TAB = '\u0000'; import { useResources } from '../hooks/useResources'; +import ResourceBookingModal from '../components/appointments/ResourceBookingModal'; import { useUrlState } from '../hooks/useUrlState'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; @@ -528,6 +527,9 @@ export default function AppointmentsPage() { const selectedResourceUuid = urlState.resource; const { resources: bookableResources } = useResources({ active: '1' }); + const [bookingResource, setBookingResource] = useState(null); + const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null; + const selectResource = (uuid: string) => { setUrlState({ resource: uuid }); if (uuid) setSelectedDoctorUuid(''); @@ -643,14 +645,6 @@ export default function AppointmentsPage() { const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null; const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid; - // ردیف منابع بخشی از همان نمای زمانبندی است: در مدل منبع‌محور، پرشدن یک ساعت را - // دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک. در نمای جدولی کوئری نمی‌رود. - const { - day: resourceDay, - loading: resourceTimelineLoading, - error: resourceTimelineError, - } = useResourceTimeline(viewMode === 'timeline' ? selectedDate : '', { onlyBookable: true }); - // ── Slots query (timeline) // effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود. const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid]; @@ -846,6 +840,8 @@ export default function AppointmentsPage() {
+ {bookingResource && ( + setBookingResource(null)} + onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })} + /> + )} + {/* مودال ثبت سریع نوبت */} {bookingSlot && (