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:
hamed
2026-07-30 14:57:21 +03:30
co-authored by Claude Opus 5
parent 0051205bf2
commit 50759bf663
6 changed files with 402 additions and 59 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } 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({
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
excludeAppointmentUuid, initialSelection,
}: {
doctorUuid: string;
date: string;
@@ -27,11 +28,18 @@ export default function ServiceSlotPicker({
editableDuration?: boolean;
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخاب‌شده خارج از context */
clinicUuidOverride?: string | null;
/**
* ویرایش نوبت: بازهٔ خودِ همین نوبت اشغال حساب نشود، وگرنه زمان فعلی‌اش در فهرست
* نمی‌آید و کاربر نمی‌تواند «همان ساعت، سرویس متفاوت» را ثبت کند.
*/
excludeAppointmentUuid?: string;
/** سرویس‌های از قبل انتخاب‌شده (ویرایش نوبت موجود). فقط یک بار هیدریت می‌شود. */
initialSelection?: PickedService[];
}) {
const contextClinicUuid = useClinicContext();
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
const [sectionUuid, setSectionUuid] = useState('');
const [selected, setSelected] = useState<PickedService[]>([]);
const [selected, setSelected] = useState<PickedService[]>(initialSelection ?? []);
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
// بخش‌های یکتا از روی سرویس‌های bookable (بدون endpoint اضافه — همه یکجا آمده‌اند).
@@ -45,8 +53,13 @@ export default function ServiceSlotPicker({
[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]);
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 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(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
),
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
});