feat: section-based service picker + per-appointment duration override (service mode)
Service-booking mode now selects services by section like slot mode: appointment-booking-services returns service_section per item; ServiceSlotPicker groups by section (SearchableSelect), accumulates picks across sections into a removable 'section -> service' chip list. Secretaries can override a service's duration for a single appointment without changing the service default: appointment-service-slots accepts durations[uuid] and both create endpoints accept service_durations; the override drives total duration and slot_end. Online (patient) booking is unaffected — it never sends overrides. Backend + frontend tests and docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
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 ServiceSlotPicker, { type ServicePick } from './ServiceSlotPicker';
|
||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const services: BookingService[] = [
|
||||
{ uuid: 'i1', name: 'بوتاکس', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||
{ uuid: 'i2', name: 'فیلر لب', duration_minutes: 20, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||
{ uuid: 'i3', name: 'لیزر موهای زائد', duration_minutes: 45, price_rials: 0, service_section: { uuid: 'sec2', name: 'لیزر' } },
|
||||
];
|
||||
|
||||
const pickSection = async (optionLabel: string) => {
|
||||
const input = document.getElementById('service-mode-section-select') as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText(optionLabel));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ success: true, data: { total_duration_minutes: 50, buffer_minutes: 0, start_times: [] } });
|
||||
});
|
||||
|
||||
describe('ServiceSlotPicker — انتخاب سرویس بر اساس بخش + مدت قابلویرایش', () => {
|
||||
it('accumulates services across sections and reports section→service + durations', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
// بخش زیبایی → دو سرویس
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
fireEvent.click(await screen.findByText('فیلر لب'));
|
||||
|
||||
// بخش لیزر → یک سرویس؛ لیست انباشته حفظ میشود
|
||||
await pickSection('لیزر');
|
||||
fireEvent.click(await screen.findByText('لیزر موهای زائد'));
|
||||
|
||||
expect(screen.getByText('سرویسهای انتخابشده (3)')).toBeInTheDocument();
|
||||
// chip نام بخش را کنار سرویس نشان میدهد (دو chip از بخش زیبایی)
|
||||
expect(screen.getAllByText('زیبایی').length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await waitFor(() => expect(last.serviceUuids).toEqual(['i1', 'i2', 'i3']));
|
||||
expect(last.durations).toEqual({ i1: 30, i2: 20, i3: 45 });
|
||||
});
|
||||
|
||||
it('lets the secretary override a service duration for this appointment only', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('مدت بوتاکس'), { target: { value: '90' } });
|
||||
await waitFor(() => expect(last.durations).toEqual({ i1: 90 }));
|
||||
});
|
||||
|
||||
it('removes a selected service from the accumulated list', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
fireEvent.click(await screen.findByText('فیلر لب'));
|
||||
expect(screen.getByText('سرویسهای انتخابشده (2)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('حذف بوتاکس'));
|
||||
expect(screen.getByText('سرویسهای انتخابشده (1)')).toBeInTheDocument();
|
||||
await waitFor(() => expect(last.serviceUuids).toEqual(['i2']));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user