From fd27ceef7dc509114a457867a75ce5a5bbfe2aa6 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 11:43:33 +0330 Subject: [PATCH] feat(appointments): book onto a resource from its tab, drop the read-only resource timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Add appointment" while a resource tab is active now opens a booking modal for that resource: its own services, then a time, then the responsible doctor. It reuses the booking engine that already existed (appointment-availability → appointment-hold → appointment-confirm) rather than adding a second path. That engine answers service-first and returns a resource assignment per slot, so the modal keeps only the slots where the engine actually offered this resource and pins that role to it on hold. Showing the other slots would let an operator pick a time that can only come back as a 409. The responsible doctor is required because every appointment has a doctor and confirm will not run without one; the resource records which device the work happens on. The read-only "منابع" timeline under the schedule is removed along with its component and hook, which had no other consumers. GET /api/v1/resources/timeline is untouched on the backend and now has no client. Co-Authored-By: Claude Opus 5 (1M context) --- .../ResourceBookingModal.test.tsx | 111 ++++++++ .../appointments/ResourceBookingModal.tsx | 227 ++++++++++++++++ .../appointments/ResourceTimeline.test.tsx | 132 ---------- .../appointments/ResourceTimeline.tsx | 242 ------------------ assets/admin/hooks/useResourceTimeline.ts | 59 ----- assets/admin/pages/AppointmentsPage.tsx | 38 ++- 6 files changed, 353 insertions(+), 456 deletions(-) create mode 100644 assets/admin/components/appointments/ResourceBookingModal.test.tsx create mode 100644 assets/admin/components/appointments/ResourceBookingModal.tsx delete mode 100644 assets/admin/components/appointments/ResourceTimeline.test.tsx delete mode 100644 assets/admin/components/appointments/ResourceTimeline.tsx delete mode 100644 assets/admin/hooks/useResourceTimeline.ts 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 && (