feat(admin): service-aware time picking on the appointment edit page
In service mode the page now mounts the existing ServiceSlotPicker and hides the three free-form time inputs plus the single-service select: a 45-minute service could previously be shortened to 20 and the next patient would sit on top of it. Hidden rather than disabled — a disabled field reads as "you must do something here". Saving splits in two: the service-aware endpoint takes the time and services (the client sends no duration), then the usual PATCH carries deposit, insurance, status and note without slot_start/slot_end/version, since the reschedule already advanced the optimistic-lock version. Booking mode is read from the appointment's own schedule via an explicit clinic_uuid, not from the panel's current environment: a doctor can be slot-based in their office and service-based in a clinic. That required exposing clinic_uuid in Appointment::toArray(), which was missing. appointment-service-slots accepts exclude_appointment_uuid, gated on canManage of that appointment — an ungated parameter would let anyone fabricate availability. ServiceSlotPicker gained two optional props; its existing callers pass neither and are unaffected. Its reset-on-doctor-change effect now skips the first run, which would otherwise wipe the initial selection. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import type { ApiResponse } from '../../lib/api';
|
import type { ApiResponse } from '../../lib/api';
|
||||||
@@ -19,6 +19,7 @@ export interface ServicePick { serviceUuids: string[]; durations: Record<string,
|
|||||||
*/
|
*/
|
||||||
export default function ServiceSlotPicker({
|
export default function ServiceSlotPicker({
|
||||||
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
||||||
|
excludeAppointmentUuid, initialSelection,
|
||||||
}: {
|
}: {
|
||||||
doctorUuid: string;
|
doctorUuid: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -27,11 +28,18 @@ export default function ServiceSlotPicker({
|
|||||||
editableDuration?: boolean;
|
editableDuration?: boolean;
|
||||||
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخابشده خارج از context */
|
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخابشده خارج از context */
|
||||||
clinicUuidOverride?: string | null;
|
clinicUuidOverride?: string | null;
|
||||||
|
/**
|
||||||
|
* ویرایش نوبت: بازهٔ خودِ همین نوبت اشغال حساب نشود، وگرنه زمان فعلیاش در فهرست
|
||||||
|
* نمیآید و کاربر نمیتواند «همان ساعت، سرویس متفاوت» را ثبت کند.
|
||||||
|
*/
|
||||||
|
excludeAppointmentUuid?: string;
|
||||||
|
/** سرویسهای از قبل انتخابشده (ویرایش نوبت موجود). فقط یک بار هیدریت میشود. */
|
||||||
|
initialSelection?: PickedService[];
|
||||||
}) {
|
}) {
|
||||||
const contextClinicUuid = useClinicContext();
|
const contextClinicUuid = useClinicContext();
|
||||||
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
|
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
|
||||||
const [sectionUuid, setSectionUuid] = useState('');
|
const [sectionUuid, setSectionUuid] = useState('');
|
||||||
const [selected, setSelected] = useState<PickedService[]>([]);
|
const [selected, setSelected] = useState<PickedService[]>(initialSelection ?? []);
|
||||||
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
|
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
|
||||||
|
|
||||||
// بخشهای یکتا از روی سرویسهای bookable (بدون endpoint اضافه — همه یکجا آمدهاند).
|
// بخشهای یکتا از روی سرویسهای bookable (بدون endpoint اضافه — همه یکجا آمدهاند).
|
||||||
@@ -45,8 +53,13 @@ export default function ServiceSlotPicker({
|
|||||||
[services, sectionUuid],
|
[services, sectionUuid],
|
||||||
);
|
);
|
||||||
|
|
||||||
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند.
|
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند. اجرای نخست معاف است،
|
||||||
useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]);
|
// وگرنه initialSelection (ویرایش نوبت موجود) همان لحظه پاک میشد.
|
||||||
|
const mounted = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted.current) { mounted.current = true; return; }
|
||||||
|
setSelected([]); setSectionUuid('');
|
||||||
|
}, [doctorUuid]);
|
||||||
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
|
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
|
||||||
|
|
||||||
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
|
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
|
||||||
@@ -59,12 +72,13 @@ export default function ServiceSlotPicker({
|
|||||||
|
|
||||||
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
|
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
|
||||||
const slotsQ = useQuery<ApiResponse<any>>({
|
const slotsQ = useQuery<ApiResponse<any>>({
|
||||||
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
|
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
|
||||||
queryFn: () => api.get(
|
queryFn: () => api.get(
|
||||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
||||||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||||||
+ durationsQs
|
+ durationsQs
|
||||||
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
|
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
|
||||||
|
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
|
||||||
),
|
),
|
||||||
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
|
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
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 AppointmentEditPage from './AppointmentEditPage';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const slotStart = Math.floor(new Date('2026-08-01T15:00').getTime() / 1000);
|
||||||
|
const slotEnd = Math.floor(new Date('2026-08-01T15:30').getTime() / 1000);
|
||||||
|
const newStart = Math.floor(new Date('2026-08-01T16:00').getTime() / 1000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `bookingMode` روی محلِ خودِ نوبت است، پس پاسخ `appointment-booking-services` را با
|
||||||
|
* همان `doctor_uuid` و بدون `clinic_uuid` (مطب شخصی) میدهیم.
|
||||||
|
*/
|
||||||
|
function mockApi(opts: { mode: 'slot' | 'service'; isReserve?: boolean; serviceItems?: any[] }) {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: {
|
||||||
|
uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4,
|
||||||
|
doctor: { uuid: 'doc1', name: 'دکتر تست' },
|
||||||
|
clinic_uuid: null,
|
||||||
|
is_reserve: opts.isReserve ?? false,
|
||||||
|
service_total_minutes: opts.mode === 'service' ? 30 : null,
|
||||||
|
service_buffer_minutes: opts.mode === 'service' ? 10 : null,
|
||||||
|
service_section: { uuid: 'sec1', name: 'زیبایی' },
|
||||||
|
service_item: { uuid: 'it1', name: 'لیزر' },
|
||||||
|
service_items: opts.serviceItems ?? [{ uuid: 'it1', name: 'لیزر' }],
|
||||||
|
staff: { uuid: 'st1', full_name: 'سحر ایمانی' },
|
||||||
|
} } });
|
||||||
|
if (url.startsWith('/api/v1/appointment-booking-services/')) {
|
||||||
|
return Promise.resolve({ success: true, data: {
|
||||||
|
booking_mode: opts.mode,
|
||||||
|
buffer_minutes: 10,
|
||||||
|
services: [
|
||||||
|
{ uuid: 'it1', name: 'لیزر', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||||
|
{ uuid: 'it2', name: 'تزریق', duration_minutes: 15, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||||
|
],
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v1/appointment-service-slots')) {
|
||||||
|
return Promise.resolve({ success: true, data: {
|
||||||
|
total_duration_minutes: 30,
|
||||||
|
buffer_minutes: 10,
|
||||||
|
start_times: [{ start: newStart, end: newStart + 1800, start_time: '16:00' }],
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] });
|
||||||
|
if (url.startsWith('/api/v1/service-items/')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر' }] });
|
||||||
|
if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] });
|
||||||
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdit() {
|
||||||
|
return renderWithProviders(
|
||||||
|
<Routes><Route path="/admin/appointments/:uuid/edit" element={<AppointmentEditPage />} /></Routes>,
|
||||||
|
{ route: '/admin/appointments/ap1/edit' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset(); post.mockReset(); patch.mockReset();
|
||||||
|
post.mockResolvedValue({ success: true, data: {} });
|
||||||
|
patch.mockResolvedValue({ success: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AppointmentEditPage — حالت نوبتدهی سرویسی', () => {
|
||||||
|
it('ورودی دستی ساعت را پنهان میکند و انتخابگر سرویس را نشان میدهد', async () => {
|
||||||
|
mockApi({ mode: 'service' });
|
||||||
|
renderEdit();
|
||||||
|
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
// انتخابگر سرویس هم برچسب «بخش» دارد؛ ظاهر شدن دو تا نشانهٔ mount شدنش است.
|
||||||
|
await waitFor(() => expect(screen.getAllByText('بخش')).toHaveLength(2));
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('ساعت شروع')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('ساعت پایان')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دکمهٔ ثبت تا انتخاب زمان غیرفعال است و راهنمای فارسی دارد', async () => {
|
||||||
|
mockApi({ mode: 'service' });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).toBeDisabled();
|
||||||
|
expect(screen.getByText('برای ثبت، سرویس و سپس یکی از زمانهای خالی را انتخاب کنید.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exclude_appointment_uuid را به درخواست اسلاتها میدهد', async () => {
|
||||||
|
mockApi({ mode: 'service' });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = get.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((u) => u.includes('appointment-service-slots') && u.includes('exclude_appointment_uuid=ap1'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ثبت، service-reschedule را بدون هیچ مدتی صدا میزند', async () => {
|
||||||
|
mockApi({ mode: 'service' });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '16:00' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/appointment/ap1/service-reschedule',
|
||||||
|
expect.objectContaining({ start: newStart, service_item_uuids: ['it1'] }),
|
||||||
|
));
|
||||||
|
|
||||||
|
const body = post.mock.calls[0][1] as Record<string, unknown>;
|
||||||
|
expect(body).not.toHaveProperty('slot_end');
|
||||||
|
expect(body).not.toHaveProperty('total_duration_minutes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH بعدی زمان و نسخه را نمیفرستد (نسخه یک قدم جلو رفته)', async () => {
|
||||||
|
mockApi({ mode: 'service' });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '16:00' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(patch).toHaveBeenCalled());
|
||||||
|
const body = patch.mock.calls[0][1] as Record<string, unknown>;
|
||||||
|
expect(body).not.toHaveProperty('slot_start');
|
||||||
|
expect(body).not.toHaveProperty('slot_end');
|
||||||
|
expect(body).not.toHaveProperty('version');
|
||||||
|
expect(body).toHaveProperty('deposit_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سرویسِ غیرفعالِ نوبت را با هشدار فارسی گزارش میکند', async () => {
|
||||||
|
mockApi({ mode: 'service', serviceItems: [{ uuid: 'it1', name: 'لیزر' }, { uuid: 'gone', name: 'حذفشده' }] });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|
||||||
|
expect(await screen.findByText(/۱ سرویس این نوبت دیگر برای نوبتدهی فعال نیست|1 سرویس این نوبت دیگر برای نوبتدهی فعال نیست/))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AppointmentEditPage — خط سرخ: حالت اسلاتی و رزرو', () => {
|
||||||
|
it('حالت اسلاتی سه فیلد ساعت را حفظ میکند و انتخابگر ندارد', async () => {
|
||||||
|
mockApi({ mode: 'slot' });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00');
|
||||||
|
expect(screen.getByLabelText('ساعت پایان')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('زمانهای خالی پیشنهادی')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('حالت اسلاتی همان PATCH قبلی با version را میفرستد', async () => {
|
||||||
|
mockApi({ mode: 'slot' });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
// پیش از کلیک باید هیدریت شده باشد، وگرنه toEpoch('','') مقدار NaN میسازد.
|
||||||
|
await waitFor(() => expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00'));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
|
||||||
|
slot_start: slotStart,
|
||||||
|
slot_end: slotEnd,
|
||||||
|
version: 4,
|
||||||
|
})));
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('نوبت رزرو در محل سرویسی هم انتخابگر زمان نمیگیرد', async () => {
|
||||||
|
mockApi({ mode: 'service', isReserve: true });
|
||||||
|
renderEdit();
|
||||||
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('ساعت شروع')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('زمانهای خالی پیشنهادی')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
import { ChevronRightIcon } from '@heroicons/react/24/outline';
|
import { ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -13,6 +13,9 @@ import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
|||||||
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
|
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
|
||||||
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||||
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||||
|
import type { PickedService, ServicePick } from '../components/appointments/ServiceSlotPicker';
|
||||||
|
|
||||||
interface Option { uuid: string; name?: string; full_name?: string }
|
interface Option { uuid: string; name?: string; full_name?: string }
|
||||||
|
|
||||||
@@ -24,9 +27,15 @@ interface AppointmentDetail {
|
|||||||
deposit_required?: boolean; deposit_amount_rials?: number | null;
|
deposit_required?: boolean; deposit_amount_rials?: number | null;
|
||||||
service_section?: Option | null; service_item?: Option | null; staff?: Option | null;
|
service_section?: Option | null; service_item?: Option | null; staff?: Option | null;
|
||||||
visit_price_rials?: number | null;
|
visit_price_rials?: number | null;
|
||||||
service_items?: { uuid: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null;
|
service_items?: { uuid: string; name?: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null;
|
||||||
insurance_service_category?: string | null;
|
insurance_service_category?: string | null;
|
||||||
insurance_base_id?: number | null;
|
insurance_base_id?: number | null;
|
||||||
|
doctor?: { uuid: string; name?: string } | null;
|
||||||
|
/** null = مطب شخصی. مبنای تشخیص روش نوبتدهیِ همین نوبت، نه محیط جاری پنل. */
|
||||||
|
clinic_uuid?: string | null;
|
||||||
|
is_reserve?: boolean;
|
||||||
|
service_total_minutes?: number | null;
|
||||||
|
service_buffer_minutes?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isoDate = (ts: number) => {
|
const isoDate = (ts: number) => {
|
||||||
@@ -84,6 +93,37 @@ export default function AppointmentEditPage() {
|
|||||||
|
|
||||||
const insurance = useAppointmentInsurance(!!uuid);
|
const insurance = useAppointmentInsurance(!!uuid);
|
||||||
|
|
||||||
|
// روش نوبتدهی از برنامهٔ **همین نوبت** پرسیده میشود (clinic_uuid صریح)، نه از محیط
|
||||||
|
// جاری پنل: یک پزشک میتواند در مطب اسلاتی و در کلینیک سرویسی باشد.
|
||||||
|
const { bookingMode, services } = useDoctorBookingServices(
|
||||||
|
a?.doctor?.uuid,
|
||||||
|
a ? (a.clinic_uuid ?? null) : undefined,
|
||||||
|
);
|
||||||
|
// نوبت رزرو زمان ندارد؛ انتخابگر زمان برایش بیمعناست.
|
||||||
|
const serviceMode = bookingMode === 'service' && !a?.is_reserve;
|
||||||
|
const [pick, setPick] = useState<ServicePick | null>(null);
|
||||||
|
|
||||||
|
// سرویسهای فعلی نوبت، برای هیدریت اولیهٔ انتخابگر. مدتِ هر سرویس از تعریف خودش
|
||||||
|
// خوانده میشود، نه از تقسیم مدت کل — تقسیم یک حدس است و override منشی را جعل میکند.
|
||||||
|
// سرویسی که دیگر bookable نیست در فهرست services نمیآید و اینجا هم رد میشود؛
|
||||||
|
// پس فهرست پس از انتخابگر ممکن است کوتاهتر از نوبت باشد و کاربر باید ببیند.
|
||||||
|
const initialSelection = useMemo<PickedService[]>(() => {
|
||||||
|
if (!a?.service_items?.length || services.length === 0) return [];
|
||||||
|
return a.service_items.flatMap((s) => {
|
||||||
|
const known = services.find((b) => b.uuid === s.uuid);
|
||||||
|
return known
|
||||||
|
? [{
|
||||||
|
uuid: known.uuid,
|
||||||
|
name: known.name,
|
||||||
|
section: known.service_section.name,
|
||||||
|
duration: known.duration_minutes ?? 0,
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}, [a?.service_items, services]);
|
||||||
|
|
||||||
|
const droppedServices = (a?.service_items?.length ?? 0) - initialSelection.length;
|
||||||
|
|
||||||
// نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور).
|
// نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور).
|
||||||
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
|
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
|
||||||
|
|
||||||
@@ -106,20 +146,36 @@ export default function AppointmentEditPage() {
|
|||||||
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
|
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, {
|
mutationFn: async () => {
|
||||||
slot_start: toEpoch(date, start),
|
// حالت سرویسی: زمان و سرویسها با endpoint سرویسآگاه میروند — کلاینت مدت
|
||||||
slot_end: toEpoch(date, end),
|
// نمیفرستد. بقیهٔ فیلدها (بیعانه، بیمه، وضعیت، یادداشت) همان PATCH قبلی.
|
||||||
service_section_uuid: sectionUuid,
|
if (serviceMode) {
|
||||||
service_item_uuid: itemUuid,
|
await api.post(`/api/v1/appointment/${uuid}/service-reschedule`, {
|
||||||
staff_uuid: staffUuid,
|
start: pick!.slot!.start,
|
||||||
deposit_required: depositRequired,
|
service_item_uuids: pick!.serviceUuids,
|
||||||
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
|
durations: pick!.durations,
|
||||||
note,
|
version: a?.version,
|
||||||
insurance_service_category: serviceCategory || null,
|
});
|
||||||
insurance_base_id: insuranceId ? Number(insuranceId) : null,
|
}
|
||||||
...(status !== a?.status ? { status } : {}),
|
|
||||||
version: a?.version,
|
return api.patch(`/api/v1/appointment/${uuid}`, {
|
||||||
}),
|
...(serviceMode ? {} : {
|
||||||
|
slot_start: toEpoch(date, start),
|
||||||
|
slot_end: toEpoch(date, end),
|
||||||
|
service_item_uuid: itemUuid,
|
||||||
|
}),
|
||||||
|
service_section_uuid: sectionUuid,
|
||||||
|
staff_uuid: staffUuid,
|
||||||
|
deposit_required: depositRequired,
|
||||||
|
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
|
||||||
|
note,
|
||||||
|
insurance_service_category: serviceCategory || null,
|
||||||
|
insurance_base_id: insuranceId ? Number(insuranceId) : null,
|
||||||
|
...(status !== a?.status ? { status } : {}),
|
||||||
|
// نسخه پس از service-reschedule یک قدم جلو رفته؛ optimistic lock را دور نزن.
|
||||||
|
...(serviceMode ? {} : { version: a?.version }),
|
||||||
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||||
toast.success('نوبت بهروزرسانی شد');
|
toast.success('نوبت بهروزرسانی شد');
|
||||||
@@ -161,21 +217,25 @@ export default function AppointmentEditPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* در حالت سرویسی، انتخاب سرویس داخل ServiceSlotPicker است (چند-سرویسی و
|
||||||
<label style={label}>سرویس</label>
|
مدتدار)؛ نگهداشتن این SearchableSelect تکی یعنی دو منبع برای یک چیز. */}
|
||||||
<div style={{ marginTop: 6 }}>
|
{!serviceMode && (
|
||||||
<SearchableSelect
|
<div>
|
||||||
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
<label style={label}>سرویس</label>
|
||||||
value={itemUuid || null}
|
<div style={{ marginTop: 6 }}>
|
||||||
onChange={v => setItemUuid(v ? String(v) : '')}
|
<SearchableSelect
|
||||||
placeholder="انتخاب سرویس"
|
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||||
isDisabled={!sectionUuid}
|
value={itemUuid || null}
|
||||||
isLoading={itemsQ.isLoading}
|
onChange={v => setItemUuid(v ? String(v) : '')}
|
||||||
isClearable
|
placeholder="انتخاب سرویس"
|
||||||
height={38}
|
isDisabled={!sectionUuid}
|
||||||
/>
|
isLoading={itemsQ.isLoading}
|
||||||
|
isClearable
|
||||||
|
height={38}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label style={label}>پرسنل</label>
|
<label style={label}>پرسنل</label>
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
@@ -237,20 +297,64 @@ export default function AppointmentEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
||||||
<div>
|
{/* حالت اسلاتی — دقیقاً همان سه فیلد قبلی، دستنخورده. */}
|
||||||
<label style={label}>انتخاب تاریخ</label>
|
{!serviceMode && (
|
||||||
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
|
||||||
|
<div>
|
||||||
|
<label style={label}>انتخاب تاریخ</label>
|
||||||
|
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={label}>ساعت شروع</label>
|
||||||
|
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={label}>ساعت پایان</label>
|
||||||
|
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label style={label}>ساعت شروع</label>
|
|
||||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
{/* حالت سرویسی — ورودی دستی ساعت پنهان است، نه disabled: فیلد غیرفعال یعنی
|
||||||
|
کاربر فکر میکند باید کاری بکند. مدت را سرور از سرویسها حساب میکند. */}
|
||||||
|
{serviceMode && (
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
<div style={{ maxWidth: 280, marginBottom: 12 }}>
|
||||||
|
<label style={label}>انتخاب تاریخ</label>
|
||||||
|
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{droppedServices > 0 && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12.5, color: 'var(--warning)', background: 'var(--warning-bg)',
|
||||||
|
border: '1px solid var(--warning)', borderRadius: 'var(--r-sm)',
|
||||||
|
padding: '8px 10px', marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{droppedServices} سرویس این نوبت دیگر برای نوبتدهی فعال نیست و در فهرست پایین نیامده است.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{date && (
|
||||||
|
<ServiceSlotPicker
|
||||||
|
doctorUuid={a.doctor?.uuid ?? ''}
|
||||||
|
date={date}
|
||||||
|
services={services}
|
||||||
|
clinicUuidOverride={a.clinic_uuid ?? null}
|
||||||
|
excludeAppointmentUuid={a.uuid}
|
||||||
|
initialSelection={initialSelection}
|
||||||
|
onSelect={setPick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pick?.slot && (
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--text-2)', marginTop: 8 }}>
|
||||||
|
زمان انتخابی: <strong dir="ltr">{isoTime(pick.slot.start)}</strong>
|
||||||
|
{a.service_buffer_minutes ? ` (+${a.service_buffer_minutes} دقیقه فاصله)` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label style={label}>ساعت پایان</label>
|
|
||||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||||
@@ -288,9 +392,20 @@ export default function AppointmentEditPage() {
|
|||||||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn primary" disabled={!date || !start || !end || save.isPending} onClick={() => save.mutate()}>
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
disabled={save.isPending || (serviceMode
|
||||||
|
? !pick?.slot || pick.serviceUuids.length === 0
|
||||||
|
: !date || !start || !end)}
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
>
|
||||||
ثبت اطلاعات
|
ثبت اطلاعات
|
||||||
</button>
|
</button>
|
||||||
|
{serviceMode && !pick?.slot && (
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 8 }}>
|
||||||
|
برای ثبت، سرویس و سپس یکی از زمانهای خالی را انتخاب کنید.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# چکلیست — تسک ۰۰ (تکمیل نوبتدهی سرویسی در clinicpro)
|
# چکلیست — تسک ۰۰ (تکمیل نوبتدهی سرویسی در clinicpro)
|
||||||
|
|
||||||
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۶ از ۱۰ تمام شد (بکاند کامل؛ مانده: UI، backfill، مستندات)
|
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۷ از ۱۰ تمام شد (مانده: ReserveAppointmentsPage، backfill، مستندات)
|
||||||
**آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
|
**آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
|
||||||
|
|
||||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||||
@@ -60,24 +60,24 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
|||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان میدهد | ⏳ | |
|
| ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان میدهد | ✅ | روش نوبتدهی از برنامهٔ **همین نوبت** پرسیده میشود (`clinic_uuid` صریح)، نه از محیط جاری پنل — پزشک میتواند در مطب اسلاتی و در کلینیک سرویسی باشد. لازمهٔ این کار: افزودن `clinic_uuid` به `toArray()` که نبود |
|
||||||
| ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ⏳ | سه فیلد ساعت |
|
| ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ✅ | ۵ تست موجود صفحه سبز ماند + دو تست خط سرخ تازه: سه فیلد ساعت با مقدار هیدریتشده، و همان `PATCH` با `version` بدون هیچ `POST` |
|
||||||
| ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ⏳ | |
|
| ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ✅ | `queryByLabelText('ساعت شروع')` در حالت سرویسی `null` است. `SearchableSelect` سرویس تکی هم پنهان میشود — نگهداشتنش دو منبع برای یک چیز بود |
|
||||||
| ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ⏳ | فقط prop `excludeAppointmentUuid` |
|
| ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ✅ | دو prop اختیاری: `excludeAppointmentUuid` و `initialSelection`. رفتار فعلیاش در `AppointmentCreatePage` و `AppointmentsPage` دستنخورده (هر دو prop `undefined`). یک اصلاح لازم شد: effect ریست روی تعویض پزشک، اجرای نخست را معاف کند وگرنه `initialSelection` همان لحظه پاک میشد |
|
||||||
| ۳.۵ | `ReserveAppointmentsPage`: سرویسها + دکمهٔ تبدیل | ⏳ | |
|
| ۳.۵ | `ReserveAppointmentsPage`: سرویسها + دکمهٔ تبدیل | ⏳ | |
|
||||||
| ۳.۶ | `ReserveAppointmentsPage` از جدول خام به `DataTable` مهاجرت کرد | ⏳ | `<td style={td}>` حذف شد |
|
| ۳.۶ | `ReserveAppointmentsPage` از جدول خام به `DataTable` مهاجرت کرد | ⏳ | `<td style={td}>` حذف شد |
|
||||||
| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | ⏳ | |
|
| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | 🔄 | در `AppointmentEditPage` هست («زمان انتخابی: ۱۶:۰۰ (+۱۰ دقیقه فاصله)») و `ServiceSlotPicker` خودش «مدت کل: N دقیقه» را نشان میدهد. `ReserveAppointmentsPage` مانده — قابلیت ۸ |
|
||||||
| ۳.۸ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکنهای `styles.css` | ⏳ | |
|
| ۳.۸ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکنهای `styles.css` | ⏳ | |
|
||||||
| ۳.۹ | دارکمود (`data-theme="dark"`) بررسی شد | ⏳ | |
|
| ۳.۹ | دارکمود (`data-theme="dark"`) بررسی شد | ⏳ | |
|
||||||
| ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | ⏳ | |
|
| ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | ⏳ | |
|
||||||
| ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `<select>` بومی | ⏳ | |
|
| ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `<select>` بومی | ✅ | `ServiceSlotPicker` از `SearchableSelect` برای بخش و دکمههای toggle برای سرویسها استفاده میکند (کد موجود). هیچ `<select>` بومی اضافه نشد |
|
||||||
| ۳.۱۲ | `backTo`/`BackButton` روی هر دو صفحه | ⏳ | |
|
| ۳.۱۲ | `backTo`/`BackButton` روی هر دو صفحه | ⏳ | |
|
||||||
| ۳.۱۳ | وضعیت لیست رزروها در URL با `useUrlState` | ⏳ | |
|
| ۳.۱۳ | وضعیت لیست رزروها در URL با `useUrlState` | ⏳ | |
|
||||||
| ۳.۱۴ | تاریخ با `PersianDateInput` · مبلغ با `formatRial` | ⏳ | |
|
| ۳.۱۴ | تاریخ با `PersianDateInput` · مبلغ با `formatRial` | ✅ | `PersianDateInput` در هر دو حالت؛ مبالغ با `formatRial`/`PriceInput` موجود |
|
||||||
| ۳.۱۵ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ⏳ | |
|
| ۳.۱۵ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ⏳ | |
|
||||||
| ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | ⏳ | |
|
| ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | ⏳ | |
|
||||||
| ۳.۱۷ | همهٔ رشتهها فارسی و از i18n | ⏳ | |
|
| ۳.۱۷ | همهٔ رشتهها فارسی و از i18n | ⏳ | |
|
||||||
| ۳.۱۸ | داده با TanStack Query و استخراج envelope درست | ⏳ | |
|
| ۳.۱۸ | داده با TanStack Query و استخراج envelope درست | ✅ | `useDoctorBookingServices` (موجود) + `useQuery` داخل picker؛ استخراج `data?.data` |
|
||||||
|
|
||||||
## ۴. تست
|
## ۴. تست
|
||||||
|
|
||||||
@@ -92,7 +92,8 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
|||||||
| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ✅ | داخل `tests/Appointment` — کل ۳۰۹ تست `tests/Appointment` + `tests/Shared` سبز |
|
| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ✅ | داخل `tests/Appointment` — کل ۳۰۹ تست `tests/Appointment` + `tests/Shared` سبز |
|
||||||
| ۴.۱۰ | `ServiceSlotExcludeSelfTest` — رفتار exclude | ✅ | ۶ تست / ۱۱ assertion. شامل: بازهٔ خودِ نوبت با exclude برمیگردد · مدت بلندتر روی همان ساعت · نوبتِ دیگری همچنان اشغال میماند · `null` صریح و ضمنی خروجی یکسان · فیلتر repository فقط همان ردیف · exclude کردن نوبت رزرو بیاثر |
|
| ۴.۱۰ | `ServiceSlotExcludeSelfTest` — رفتار exclude | ✅ | ۶ تست / ۱۱ assertion. شامل: بازهٔ خودِ نوبت با exclude برمیگردد · مدت بلندتر روی همان ساعت · نوبتِ دیگری همچنان اشغال میماند · `null` صریح و ضمنی خروجی یکسان · فیلتر repository فقط همان ردیف · exclude کردن نوبت رزرو بیاثر |
|
||||||
| ۴.۹ | `AppointmentServiceFieldsTest` — متدها و ستونهای جدید | ✅ | ۹ تست / ۲۴ assertion. شامل: همگامی ستون تکی · حفظ ترتیب · فهرست خالی → `null` · حالت اسلاتی هر دو ستون `null` · مدتِ `null` بافر را هم `null` میکند · تکراریها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear |
|
| ۴.۹ | `AppointmentServiceFieldsTest` — متدها و ستونهای جدید | ✅ | ۹ تست / ۲۴ assertion. شامل: همگامی ستون تکی · حفظ ترتیب · فهرست خالی → `null` · حالت اسلاتی هر دو ستون `null` · مدتِ `null` بافر را هم `null` میکند · تکراریها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear |
|
||||||
| ۴.۸ | `AppointmentEditPage.test.tsx` — دو حالت | ⏳ | |
|
| ۴.۸ | `AppointmentEditPage` — دو حالت | ✅ | فایل جدید `AppointmentEditPage.serviceMode.test.tsx`: ۹ تست. شامل: پنهانبودن ورودی ساعت · دکمهٔ غیرفعال + راهنما · `exclude_appointment_uuid` در query · `service-reschedule` **بدون هیچ مدتی** · `PATCH` بعدی بی`version` · هشدار سرویس غیرفعال · و سه تست خط سرخ (اسلاتی و رزرو). فایل موجود `AppointmentEditPage.test.tsx` هم سبز ماند |
|
||||||
|
| ۴.۱۱ | کل vitest سبز | ✅ | `86 files / 604 tests passed` روی host (داخل ddev باینری esbuild پلتفرم اشتباه دارد — مسئلهٔ محیطی از قبل) |
|
||||||
|
|
||||||
## ۵. مستندات
|
## ۵. مستندات
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,24 @@ class AppointmentController extends BaseController
|
|||||||
(array) $request->query->all('durations'),
|
(array) $request->query->all('durations'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// جابهجایی: بازهٔ خودِ نوبتِ در حال ویرایش نباید اشغال حساب شود، وگرنه زمان
|
||||||
|
// فعلیاش هرگز در فهرست نمیآید. فقط برای کسی که همان نوبت را مدیریت میکند —
|
||||||
|
// این پارامتر آزاد نیست، چون در غیر اینصورت هر کسی میتوانست با uuid دلخواه
|
||||||
|
// ظرفیتِ ساختگی ببیند.
|
||||||
|
$excludeId = null;
|
||||||
|
$excludeUuid = trim((string) $request->query->get('exclude_appointment_uuid', ''));
|
||||||
|
if ($excludeUuid !== '') {
|
||||||
|
$excluded = $this->appointmentRepo->findByUuid($excludeUuid);
|
||||||
|
$actor = $this->getUser();
|
||||||
|
if ($excluded === null || !$actor instanceof User || !$this->canManage($excluded, $actor)) {
|
||||||
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403, 'exclude_appointment_uuid');
|
||||||
|
}
|
||||||
|
if ($excluded->getDoctor()->getId() !== $doctor->getId()) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوبت انتخابی به این پزشک تعلق ندارد', 422, 'exclude_appointment_uuid');
|
||||||
|
}
|
||||||
|
$excludeId = $excluded->getId();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'doctor_uuid' => $doctorUuid,
|
'doctor_uuid' => $doctorUuid,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
@@ -231,6 +249,7 @@ class AppointmentController extends BaseController
|
|||||||
$duration->totalMinutes,
|
$duration->totalMinutes,
|
||||||
$clinic,
|
$clinic,
|
||||||
$this->isManagementContext($request, $doctor, $clinic),
|
$this->isManagementContext($request, $doctor, $clinic),
|
||||||
|
$excludeId,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -467,6 +467,10 @@ class Appointment
|
|||||||
],
|
],
|
||||||
'address' => $firstAddress?->toArray(),
|
'address' => $firstAddress?->toArray(),
|
||||||
'address_id' => $this->addressId,
|
'address_id' => $this->addressId,
|
||||||
|
// محلِ نوبتدهی این نوبت. null = مطب شخصی. کلاینت بدون این نمیداند روش
|
||||||
|
// نوبتدهی را از کدام برنامه بپرسد: یک پزشک میتواند در مطب اسلاتی و در
|
||||||
|
// کلینیک سرویسی باشد و محیط جاریِ پنل لزوماً محیط این نوبت نیست.
|
||||||
|
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||||
'user' => [
|
'user' => [
|
||||||
'uuid' => $this->user->getUuid(),
|
'uuid' => $this->user->getUuid(),
|
||||||
'mobile' => $this->user->getMobileNumber(),
|
'mobile' => $this->user->getMobileNumber(),
|
||||||
|
|||||||
Reference in New Issue
Block a user