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
+158 -43
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, Link } from 'react-router-dom';
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 BackButton from '../components/ui/BackButton';
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 }
@@ -24,9 +27,15 @@ interface AppointmentDetail {
deposit_required?: boolean; deposit_amount_rials?: number | null;
service_section?: Option | null; service_item?: Option | null; staff?: Option | 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_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) => {
@@ -84,6 +93,37 @@ export default function AppointmentEditPage() {
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 (همان قاعدهٔ سرور).
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 save = useMutation({
mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, {
slot_start: toEpoch(date, start),
slot_end: toEpoch(date, end),
service_section_uuid: sectionUuid,
service_item_uuid: itemUuid,
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 } : {}),
version: a?.version,
}),
mutationFn: async () => {
// حالت سرویسی: زمان و سرویس‌ها با endpoint سرویس‌آگاه می‌روند — کلاینت مدت
// نمی‌فرستد. بقیهٔ فیلدها (بیعانه، بیمه، وضعیت، یادداشت) همان PATCH قبلی.
if (serviceMode) {
await api.post(`/api/v1/appointment/${uuid}/service-reschedule`, {
start: pick!.slot!.start,
service_item_uuids: pick!.serviceUuids,
durations: pick!.durations,
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: () => {
qc.invalidateQueries({ queryKey: ['appointments'] });
toast.success('نوبت به‌روزرسانی شد');
@@ -161,21 +217,25 @@ export default function AppointmentEditPage() {
/>
</div>
</div>
<div>
<label style={label}>سرویس</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={itemUuid || null}
onChange={v => setItemUuid(v ? String(v) : '')}
placeholder="انتخاب سرویس"
isDisabled={!sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={38}
/>
{/* در حالت سرویسی، انتخاب سرویس داخل ServiceSlotPicker است (چند-سرویسی و
مدت‌دار)؛ نگه‌داشتن این SearchableSelect تکی یعنی دو منبع برای یک چیز. */}
{!serviceMode && (
<div>
<label style={label}>سرویس</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={itemUuid || null}
onChange={v => setItemUuid(v ? String(v) : '')}
placeholder="انتخاب سرویس"
isDisabled={!sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={38}
/>
</div>
</div>
</div>
)}
<div>
<label style={label}>پرسنل</label>
<div style={{ marginTop: 6 }}>
@@ -237,20 +297,64 @@ export default function AppointmentEditPage() {
</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>
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
{/* حالت اسلاتی — دقیقاً همان سه فیلد قبلی، دست‌نخورده. */}
{!serviceMode && (
<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>
<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>
<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={{ 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' }} />
</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>
{serviceMode && !pick?.slot && (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 8 }}>
برای ثبت، سرویس و سپس یکی از زمانهای خالی را انتخاب کنید.
</div>
)}
</div>
</div>
);