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:
@@ -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()}
|
||||
>
|
||||
انتقال و حذف از لیست
|
||||
|
||||
Reference in New Issue
Block a user