refactor(resource): remove ResourceBookingModal component and its tests
This commit is contained in:
@@ -1,129 +0,0 @@
|
|||||||
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',
|
|
||||||
supervisor: { uuid: 'd-1', name: 'دکتر مرادی' },
|
|
||||||
} 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* پزشک از ناظرِ خودِ منبع میآید، نه از یک انتخابگر دیگر — ارتباط پزشک↔منبع یک جا
|
|
||||||
* تعریف شده و تکرارش اینجا یعنی دو منبعِ حقیقت.
|
|
||||||
*/
|
|
||||||
it('پزشک را از ناظرِ منبع میگیرد و انتخابگر پزشک ندارد', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithProviders(
|
|
||||||
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await screen.findByText('ثبت نوبت — لیزر CO2');
|
|
||||||
expect(screen.getByText(/پزشک ناظر: دکتر مرادی/)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('پزشک انجامدهنده')).toBeNull();
|
|
||||||
|
|
||||||
// بدون سرویس و زمان هنوز غیرفعال است.
|
|
||||||
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!);
|
|
||||||
|
|
||||||
// سرویس + زمان + ناظر ⇒ حالا قابل ثبت است.
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeEnabled());
|
|
||||||
});
|
|
||||||
|
|
||||||
/** منبعی که ناظرش حذف شده نباید بیصدا غیرقابلثبت بماند. */
|
|
||||||
it('منبع بدون ناظر را با پیام روشن مسدود میکند', async () => {
|
|
||||||
const orphan = { uuid: 'r-x', name: 'لیزر یتیم', address_uuid: 'addr-1', supervisor: null } as never;
|
|
||||||
renderWithProviders(
|
|
||||||
<ResourceBookingModal resource={orphan} onClose={vi.fn()} onBooked={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await screen.findByText(/این منبع پزشک ناظر ندارد/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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 [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// پزشکِ نوبت از ناظرِ خودِ منبع میآید، نه از یک انتخابگرِ دیگر: ارتباط پزشک و منبع
|
|
||||||
// یک جا تعریف شده (فرم منبع) و تکرارش اینجا یعنی دو منبعِ حقیقت.
|
|
||||||
const doctorUuid = resource.supervisor?.uuid ?? '';
|
|
||||||
|
|
||||||
// سرویسهای همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمیدهد نباید
|
|
||||||
// در فهرست بیاید — سرور هم همان را با ۴۲۲ رد میکند.
|
|
||||||
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 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;
|
|
||||||
// ناظر در API الزامی است؛ ردیفهای قدیمیای که پزشکشان حذف شده `supervisor: null` دارند.
|
|
||||||
const missingSupervisor = !resource.supervisor;
|
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{missingSupervisor && (
|
|
||||||
<p className="field-err" role="alert" style={{ marginTop: 0 }}>
|
|
||||||
این منبع پزشک ناظر ندارد و نوبتی برایش ثبت نمیشود — از «منابع» ناظرش را تعیین کنید.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="toggle-row">
|
|
||||||
<div>
|
|
||||||
<div className="tr-title">پزشک ناظر: {resource.supervisor?.name ?? '—'}</div>
|
|
||||||
<div className="tr-desc">
|
|
||||||
نوبت به نام همین پزشک ثبت میشود. برای تغییرش، ناظرِ منبع را در «منابع» ویرایش کنید.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user