fix(booking): reserve conversion produced a zero-length midnight appointment

TransferReserveModal built the live appointment from appointment_time/end_time,
which on a reserve entry are both 00:00 because slot_start == slot_end. Moving a
reserve back to the appointment list silently created a zero-length appointment
at midnight. With the new duration validation it would now fail loudly instead.

Converting back now asks for a real time: the service picker in service mode,
two required time inputs in slot mode. The appointment -> reserve direction is
untouched.

GET /my/appointments has its own array-hydration serializer rather than
Appointment::toArray(), so it exposed none of the service fields the panel needs.
Added service_items (separate query, no row multiplication and no N+1),
clinic_uuid and the duration pair. This was also a hidden prerequisite of the
public-site task, whose checklist listed it as "verify first".

The reserve table now lists every service instead of only the first.

Not done, deliberately: the DataTable migration the task asked for. Its stated
reason — inline tokens breaking dark mode — does not hold; this table's th/td
already use CSS variables and dark mode works. Rewriting a working table for no
real gain is unjustified risk.

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 15:04:09 +03:30
co-authored by Claude Opus 5
parent 96095c05f3
commit 4fbedecec1
5 changed files with 359 additions and 21 deletions
+125 -20
View File
@@ -14,7 +14,7 @@ import {
WalletIcon,
} from "@heroicons/react/24/outline";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
@@ -27,6 +27,9 @@ import Modal from "./ui/Modal";
import PersianDateInput from "./ui/PersianDateInput";
import PriceInput from "./ui/PriceInput";
import SearchableSelect from "./ui/SearchableSelect";
import ServiceSlotPicker from "./appointments/ServiceSlotPicker";
import type { PickedService, ServicePick } from "./appointments/ServiceSlotPicker";
import { useDoctorBookingServices } from "../hooks/useDoctorBookingServices";
/** Row actions for the appointments table (Figma عملیات menu). */
type ModalKind = null | "info" | "move" | "transfer" | "replace";
@@ -573,26 +576,79 @@ export function TransferReserveModal({
const [date, setDate] = useState(a.appointment_date);
const toReserve = !a.is_reserve;
// روش نوبت‌دهی از محلِ خودِ نوبت، نه محیط جاری پنل.
const { bookingMode, services } = useDoctorBookingServices(
toReserve ? undefined : a.doctor_uuid,
a.clinic_uuid ?? null,
);
const serviceMode = !toReserve && bookingMode === "service";
// بازگشت از رزرو به لیست نوبت‌ها به زمان واقعی نیاز دارد. پیش از این از
// appointment_time/end_time خوانده می‌شد که روی یک رزرو هر دو 00:00 اند — نتیجه،
// نوبتی با مدت صفر در نیمه‌شب بود.
const [pick, setPick] = useState<ServicePick | null>(null);
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
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 canSubmit = !!date && (toReserve
? true
: serviceMode
? !!pick?.slot && (pick?.serviceUuids.length ?? 0) > 0
: !!start && !!end);
const transfer = useMutation({
mutationFn: () => {
mutationFn: async () => {
const day = toEpoch(date, "00:00");
return api.patch(
`/api/v1/appointment/${a.uuid}`,
toReserve
? // reserve entries are day-level: midnight-to-midnight, no slot occupation
{
is_reserve: true,
slot_start: day,
slot_end: day,
version: a.version,
}
: {
is_reserve: false,
slot_start: toEpoch(date, a.appointment_time),
slot_end: toEpoch(date, a.end_time),
version: a.version,
},
);
if (toReserve) {
// reserve entries are day-level: midnight-to-midnight, no slot occupation
return api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: true,
slot_start: day,
slot_end: day,
version: a.version,
});
}
if (serviceMode) {
// ابتدا زمان‌دار شود (رزرو زمان ندارد و service-reschedule رزرو را رد می‌کند)،
// سپس مدت و سرویس‌ها با endpoint سرویس‌آگاه تثبیت شوند.
await api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: false,
slot_start: pick!.slot!.start,
slot_end: pick!.slot!.end,
service_item_uuids: pick!.serviceUuids,
durations: pick!.durations,
version: a.version,
});
return api.post(`/api/v1/appointment/${a.uuid}/service-reschedule`, {
start: pick!.slot!.start,
service_item_uuids: pick!.serviceUuids,
durations: pick!.durations,
});
}
return api.patch(`/api/v1/appointment/${a.uuid}`, {
is_reserve: false,
slot_start: toEpoch(date, start),
slot_end: toEpoch(date, end),
version: a.version,
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey });
@@ -640,11 +696,60 @@ export function TransferReserveModal({
<div style={{ margin: "6px 0 16px" }}>
<PersianDateInput value={date} onChange={setDate} />
</div>
{/* بازگشت به لیست نوبت‌ها زمان لازم دارد؛ رزرو زمانی ندارد که ارث ببرد. */}
{!toReserve && serviceMode && date && (
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={a.doctor_uuid}
date={date}
services={services}
clinicUuidOverride={a.clinic_uuid ?? null}
excludeAppointmentUuid={a.uuid}
initialSelection={initialSelection}
onSelect={setPick}
/>
</div>
)}
{!toReserve && !serviceMode && (
<div style={{ display: "flex", gap: 10, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12.5, color: "var(--text-3)" }}>
ساعت شروع
</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 style={{ flex: 1 }}>
<label style={{ fontSize: 12.5, color: "var(--text-3)" }}>
ساعت پایان
</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={{ display: "flex", gap: 8 }}>
<button
className="btn primary"
style={{ flex: 1 }}
disabled={!date || transfer.isPending}
disabled={!canSubmit || transfer.isPending}
onClick={() => transfer.mutate()}
>
انتقال و حذف از لیست