feat(appointments): book onto a resource from its tab, drop the read-only resource timeline
"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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<AvailableSlot | null>(null);
|
||||
const [doctorUuid, setDoctorUuid] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// سرویسهای همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمیدهد نباید
|
||||
// در فهرست بیاید — سرور هم همان را با ۴۲۲ رد میکند.
|
||||
const offeringsQuery = useQuery<ApiResponse<ResourceServiceOffering[]>>({
|
||||
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<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 } = 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 (
|
||||
<Modal
|
||||
open
|
||||
title={`ثبت نوبت — ${resource.name}`}
|
||||
size="sm"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn ghost" onClick={onClose}>انصراف</button>
|
||||
<button type="button" className="btn primary" disabled={!canSubmit} onClick={submit}>
|
||||
{submitting ? 'در حال ثبت…' : 'ثبت نوبت'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label htmlFor="rb-service">سرویسهای این منبع <span className="req">*</span></label>
|
||||
{offeringsQuery.isLoading ? (
|
||||
<div className="skeleton" style={{ height: 40, borderRadius: 'var(--r-sm)' }} />
|
||||
) : offerings.length === 0 ? (
|
||||
<p className="field-err" style={{ marginTop: 0 }}>
|
||||
برای این منبع سرویسی تعریف نشده است — از تب «سرویسها»ی همین منبع اضافه کنید.
|
||||
</p>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
inputId="rb-service"
|
||||
options={offerings.map((o) => ({
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serviceUuid && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label htmlFor="rb-range">بازهٔ جستجو</label>
|
||||
<SearchableSelect
|
||||
inputId="rb-range"
|
||||
options={[
|
||||
{ value: '1', label: 'امروز' },
|
||||
{ value: '7', label: 'یک هفتهٔ آینده' },
|
||||
{ value: '30', label: 'یک ماه آینده' },
|
||||
]}
|
||||
value={days}
|
||||
onChange={(v) => { setDays(v ? String(v) : '7'); setPickedSlot(null); }}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serviceUuid && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<span className="field-label">زمانهای خالی این منبع</span>
|
||||
{loading ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>در حال محاسبه…</div>
|
||||
) : slots.length === 0 ? (
|
||||
<p className="field-err" style={{ marginTop: 0 }}>
|
||||
{reasonText ?? 'برای این منبع در این بازه وقت آزادی نیست — بازه را بزرگتر کنید.'}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{slots.slice(0, 40).map((s) => {
|
||||
const active = pickedSlot?.start === s.start;
|
||||
return (
|
||||
<button
|
||||
key={s.start}
|
||||
type="button"
|
||||
dir="ltr"
|
||||
onClick={() => setPickedSlot(s)}
|
||||
style={{
|
||||
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary)' : 'var(--surface)',
|
||||
color: active ? 'var(--on-primary)' : 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{new Date(s.start * 1000).toLocaleDateString('fa-IR')} · {hhmm(s.start)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickedSlot && (
|
||||
<div className="field-block">
|
||||
<label htmlFor="rb-doctor">پزشک مسئول <span className="req">*</span></label>
|
||||
<SearchableSelect
|
||||
inputId="rb-doctor"
|
||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
||||
value={doctorUuid || null}
|
||||
onChange={(v) => setDoctorUuid(v ? String(v) : '')}
|
||||
placeholder="پزشک انجامدهنده"
|
||||
height={40}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
هر نوبت پزشک مسئول دارد؛ منبع میگوید کار روی چه دستگاهی انجام میشود.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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> = {}): 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(
|
||||
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('لیزر دایود')).toBeInTheDocument();
|
||||
expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument();
|
||||
expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('بلوک به جزئیات همان نوبت لینک میدهد', () => {
|
||||
renderWithProviders(
|
||||
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceTimeline lanes={[lane(), room]} dayStart={DAY_START} loading={false} error={null} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument();
|
||||
expect(screen.getByText('زهرا احمدی · بیحسی موضعی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('روزِ بدون شیفت و بدون نوبت را صریح میگوید', () => {
|
||||
renderWithProviders(
|
||||
<ResourceTimeline lanes={[lane({ shifts: [], items: [] })]} dayStart={DAY_START} loading={false} error={null} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('این روز شیفتی ندارد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('بدون هیچ منبعی به صفحهٔ تعریف منبع راه میدهد', () => {
|
||||
renderWithProviders(
|
||||
<ResourceTimeline lanes={[]} dayStart={DAY_START} loading={false} error={null} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('تنظیمات ← منابع').closest('a')).toHaveAttribute('href', '/admin/resources');
|
||||
});
|
||||
|
||||
it('خطای سرور را نشان میدهد، نه تایملاین خالی', () => {
|
||||
renderWithProviders(
|
||||
<ResourceTimeline lanes={[]} dayStart={DAY_START} loading={false} error="خطای داخلی سرور" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** ظرفیت و وقت آزاد باید روی خودِ ردیف باشد، نه حدسِ کاربر از روی بلوکها. */
|
||||
it('وقت آزاد هر ردیف را نشان میدهد', () => {
|
||||
renderWithProviders(
|
||||
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('۴۲۰ دقیقه آزاد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ردیفِ پرشده «تکمیل» میگوید، نه صفر دقیقه', () => {
|
||||
renderWithProviders(
|
||||
<ResourceTimeline
|
||||
lanes={[lane({ busy_minutes: 480, free_minutes: 0 })]}
|
||||
dayStart={DAY_START}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('تکمیل')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ظرفیت بیش از یک را کنار نوع منبع میآورد', () => {
|
||||
renderWithProviders(
|
||||
<ResourceTimeline lanes={[lane({ capacity: 3 })]} dayStart={DAY_START} loading={false} error={null} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/ظرفیت ۳/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: 40, textAlign: 'center', color: 'var(--danger)' }}>{error}</div>;
|
||||
}
|
||||
|
||||
if (lanes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)', fontSize: 13, lineHeight: 2 }}>
|
||||
هنوز منبعی تعریف نشده است.
|
||||
<br />
|
||||
<Link to="/admin/resources" style={{ color: 'var(--primary)' }}>تنظیمات ← منابع</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: 720 }}>
|
||||
{/* خطکش ساعت */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: 6 }}>
|
||||
<div style={{ width: LABEL_WIDTH, flexShrink: 0 }} />
|
||||
<div style={{ position: 'relative', flex: 1, height: 18 }}>
|
||||
{hours.map((m, i) => (
|
||||
<span
|
||||
key={m}
|
||||
dir="ltr"
|
||||
style={{
|
||||
position: 'absolute', right: `${((m - from) / span) * 100}%`,
|
||||
// برچسبِ دو سرِ خطکش وسطچین نمیشود، وگرنه نصفش بیرون قاب میافتد
|
||||
// و «۲۱» به «۲» تبدیل میشود.
|
||||
transform: i === 0 ? 'translateX(0)'
|
||||
: i === hours.length - 1 ? 'translateX(100%)'
|
||||
: 'translateX(50%)',
|
||||
fontSize: 11, color: 'var(--text-3)',
|
||||
}}
|
||||
>
|
||||
{hourLabel(m)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lanes.map((lane) => (
|
||||
<div key={lane.uuid} style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
||||
<div style={{ width: LABEL_WIDTH, flexShrink: 0, paddingLeft: 10, minWidth: 0 }}>
|
||||
<Link
|
||||
to={`/admin/resources/${lane.uuid}`}
|
||||
style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none', display: 'block' }}
|
||||
>
|
||||
{lane.name}
|
||||
</Link>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
||||
{lane.type_name}
|
||||
{lane.capacity > 1 && <> · ظرفیت {formatNumber(lane.capacity)}</>}
|
||||
</span>
|
||||
<LaneLoad lane={lane} />
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: 'relative', flex: 1, height: LANE_HEIGHT,
|
||||
background: 'var(--surface-2)', borderRadius: 'var(--r-sm)', overflow: 'hidden',
|
||||
}}>
|
||||
{/* شیفت کاری: پسزمینهٔ روشنتر، تا «تعطیل» از «آزاد» فرق کند */}
|
||||
{lane.shifts.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute', top: 0, bottom: 0,
|
||||
right: `${((s.start_minute - from) / span) * 100}%`,
|
||||
width: `${((s.end_minute - s.start_minute) / span) * 100}%`,
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hours.map((m) => (
|
||||
<div
|
||||
key={m}
|
||||
style={{
|
||||
position: 'absolute', top: 0, bottom: 0, right: `${((m - from) / span) * 100}%`,
|
||||
width: 1, background: 'var(--border)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{lane.items.map((item) => (
|
||||
<Block key={item.uuid} item={item} dayStart={dayStart} from={from} span={span} />
|
||||
))}
|
||||
|
||||
{lane.shifts.length === 0 && lane.items.length === 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11.5, color: 'var(--text-3)',
|
||||
}}>
|
||||
این روز شیفتی ندارد
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* «چقدر از این ردیف هنوز جا دارد» — با ظرفیت حساب شده، نه پر/خالی.
|
||||
*
|
||||
* منبع ظرفیت۳ که دو نوبت همزمان دارد هنوز آزاد است؛ بدون این عدد، کاربر از روی
|
||||
* بلوکهای تودرتو باید حدس بزند.
|
||||
*/
|
||||
function LaneLoad({ lane }: { lane: ResourceTimelineLane }) {
|
||||
if (lane.shift_minutes === 0) {
|
||||
return <span style={{ fontSize: 11, color: 'var(--text-3)' }}>بدون شیفت</span>;
|
||||
}
|
||||
|
||||
const full = lane.free_minutes === 0;
|
||||
|
||||
return (
|
||||
<span style={{ fontSize: 11, color: full ? 'var(--danger)' : 'var(--success)' }}>
|
||||
{full ? 'تکمیل' : `${formatNumber(lane.free_minutes)} دقیقه آزاد`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<span style={{
|
||||
display: 'block', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
fontSize: 11.5, fontWeight: 600, lineHeight: '30px', padding: '0 6px',
|
||||
color: hold ? 'var(--warning)' : 'var(--on-primary)',
|
||||
}}>
|
||||
{label || hhmm(item.starts_at)}
|
||||
</span>
|
||||
);
|
||||
|
||||
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 <div title={title} style={style}>{body}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={`/admin/appointments/${item.appointment_uuid}`} title={title} style={style}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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<ApiResponse<ResourceTimelineDay>>(`/api/v1/resources/timeline?${params}`),
|
||||
enabled: !!date,
|
||||
});
|
||||
|
||||
return {
|
||||
day: query.data?.data,
|
||||
loading: query.isLoading,
|
||||
error: query.isError ? ((query.error as Error)?.message || 'خطای نامشخص') : null,
|
||||
};
|
||||
}
|
||||
@@ -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<ClinicResource | null>(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() {
|
||||
<button
|
||||
className="btn primary sm"
|
||||
onClick={() => {
|
||||
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویسهای خودش.
|
||||
if (activeResource) { setBookingResource(activeResource); return; }
|
||||
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
|
||||
navigate(`/admin/appointments/new${q}`);
|
||||
}}
|
||||
@@ -932,24 +928,20 @@ export default function AppointmentsPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت میتواند همزمان اتاق و
|
||||
دستگاه را بگیرد و آن است که ظرفیت را تمام میکند. */}
|
||||
<div style={{ marginTop: 'var(--gap)', paddingTop: 'var(--gap)', borderTop: '1px solid var(--border)' }}>
|
||||
<h2 className="section-title" style={{ margin: '0 0 12px', fontSize: 15 }}>منابع</h2>
|
||||
<ResourceTimeline
|
||||
lanes={resourceDay?.resources ?? []}
|
||||
dayStart={resourceDay?.date ?? 0}
|
||||
loading={resourceTimelineLoading}
|
||||
error={resourceTimelineError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bookingResource && (
|
||||
<ResourceBookingModal
|
||||
resource={bookingResource}
|
||||
onClose={() => setBookingResource(null)}
|
||||
onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* مودال ثبت سریع نوبت */}
|
||||
{bookingSlot && (
|
||||
<NewAppointmentModal
|
||||
|
||||
Reference in New Issue
Block a user