feat: Implement resource-based appointment booking flow
- Updated DoctorPage component to accept bookingResources prop for appointment list. - Added serviceQuery function to serialize service item UUIDs for API requests. - Introduced new API endpoints for fetching booking resources and resource slots. - Enhanced tests for new resource-based booking functionality, including resource selection and service availability. - Created ResourceSelect component for selecting appointment types, including doctor and resource options. - Updated appointment submission logic to include resource_uuid in payload when applicable. - Ensured UI reflects changes in booking flow without disrupting existing doctor-centric experience.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ResourceSelect from './index';
|
||||
|
||||
const resource = (over = {}) => ({
|
||||
uuid: 'r1',
|
||||
name: 'کندلا2021',
|
||||
type: { code: 'laser_device', name: 'دستگاه لیزر' },
|
||||
services: [{ uuid: 's1', name: 'لیزر دست', duration_minutes: 20, price_rials: 2000000 }],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('ResourceSelect', () => {
|
||||
it('کارت «نوبت با پزشک» همیشه اول میآید', () => {
|
||||
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0]).toHaveTextContent('نوبت با پزشک');
|
||||
expect(buttons[1]).toHaveTextContent('کندلا2021');
|
||||
});
|
||||
|
||||
it('نوع منبع بهعنوان برچسب دیده میشود', () => {
|
||||
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
|
||||
expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('انتخاب «نوبت با پزشک» مقدار null میفرستد', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ResourceSelect resources={[resource()]} onSelect={onSelect} />);
|
||||
|
||||
await userEvent.click(screen.getByText('نوبت با پزشک'));
|
||||
expect(onSelect).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('انتخاب منبع، خودِ آبجکت منبع را میفرستد', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const r = resource();
|
||||
render(<ResourceSelect resources={[r]} onSelect={onSelect} />);
|
||||
|
||||
await userEvent.click(screen.getByText('کندلا2021'));
|
||||
expect(onSelect).toHaveBeenCalledWith(r);
|
||||
});
|
||||
|
||||
it('تا سه سرویس با نام میآید و بقیه با +n', () => {
|
||||
const services = ['الف', 'ب', 'ج', 'د', 'ه'].map((n, i) => ({ uuid: `s${i}`, name: n }));
|
||||
render(<ResourceSelect resources={[resource({ services })]} onSelect={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('الف، ب، ج +2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('منبعِ بدون سرویس، زیرنویس خالی نمیسازد', () => {
|
||||
render(<ResourceSelect resources={[resource({ services: [] })]} onSelect={vi.fn()} />);
|
||||
|
||||
// فقط زیرنویسِ کارتِ پزشک میماند.
|
||||
expect(screen.getAllByRole('button')[1].textContent).toBe('کندلا2021دستگاه لیزر');
|
||||
});
|
||||
|
||||
it('منبعِ بدون type برچسب نمیگیرد و نمیشکند', () => {
|
||||
render(<ResourceSelect resources={[resource({ type: null })]} onSelect={vi.fn()} />);
|
||||
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
const MAX_PREVIEW_SERVICES = 3;
|
||||
|
||||
/**
|
||||
* زیرنویس کارت: نام سرویسها تا سهتا و بعد «+n».
|
||||
*
|
||||
* شمارِ خالی («۳ سرویس») به بیمار نمیگوید این دستگاه به کارش میآید یا نه، و او را
|
||||
* مجبور میکند کورکورانه کلیک کند.
|
||||
*/
|
||||
function servicesLabel(services = []) {
|
||||
if (services.length === 0) return null;
|
||||
|
||||
const shown = services.slice(0, MAX_PREVIEW_SERVICES).map((s) => s.name);
|
||||
const rest = services.length - shown.length;
|
||||
|
||||
return rest > 0 ? `${shown.join("، ")} +${rest}` : shown.join("، ");
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخاب نوع نوبت — پیش از انتخاب سرویس و روز، و فقط وقتی این پزشک در این محل
|
||||
* دستکم یک منبعِ قابل رزرو دارد.
|
||||
*
|
||||
* «منبع» واژهٔ داخلی است و به بیمار نشان داده نمیشود؛ کارتها نام واقعی دستگاه/اتاق
|
||||
* و نوعشان را نشان میدهند. کارت اول همیشه جریان همیشگی است: نوبت با خودِ پزشک.
|
||||
*/
|
||||
function ResourceSelect({ resources = [], selected = null, onSelect, allowDoctorOption = true }) {
|
||||
const cardClass = (active) =>
|
||||
`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
|
||||
active
|
||||
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
|
||||
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] mx-auto">
|
||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب نوع نوبت</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* پزشکی که برنامهٔ هفتگی ندارد، ویزیتِ مستقیم هم ندارد؛ نشاندادن این کارت
|
||||
کاربر را به فهرست ساعتِ همیشهخالی میبرد. */}
|
||||
{allowDoctorOption && (
|
||||
<button type="button" onClick={() => onSelect(null)} className={cardClass(selected === null)}>
|
||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">نوبت با پزشک</p>
|
||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
|
||||
ویزیت معمول در ساعتهای کاری پزشک
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{resources.map((resource) => {
|
||||
const active = selected?.uuid === resource.uuid;
|
||||
const label = servicesLabel(resource.services);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={resource.uuid}
|
||||
type="button"
|
||||
onClick={() => onSelect(resource)}
|
||||
className={cardClass(active)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-[8px]">
|
||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
|
||||
{resource.name}
|
||||
</p>
|
||||
{resource.type?.name && (
|
||||
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px] shrink-0">
|
||||
{resource.type.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{label && (
|
||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">{label}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourceSelect;
|
||||
Reference in New Issue
Block a user